├── .gitignore ├── lib ├── Exception.php ├── BadUriException.php ├── BadOpcodeException.php ├── TimeoutException.php ├── Message │ ├── Close.php │ ├── Ping.php │ ├── Pong.php │ ├── Text.php │ ├── Binary.php │ ├── Factory.php │ └── Message.php ├── OpcodeTrait.php ├── ConnectionException.php ├── Server.php ├── Client.php └── Connection.php ├── tests ├── bootstrap.php ├── mock │ ├── payload.128.txt │ ├── EchoLog.php │ ├── mock-socket.php │ └── MockSocket.php ├── scripts │ ├── client.connect-bad-context.json │ ├── send-bad-opcode.json │ ├── server.construct.json │ ├── server.accept-failed-connect.json │ ├── client.destruct.json │ ├── receive-bad-opcode.json │ ├── server.accept-error-connect.json │ ├── client.connect-failed.json │ ├── server.construct-failed-socket-server.json │ ├── server.disconnect.json │ ├── config-timeout.json │ ├── client.connect-error.json │ ├── client.connect-bad-stream.json │ ├── server.construct-error-socket-server.json │ ├── server.accept-failed-handshake.json │ ├── client.connect-persistent-failure.json │ ├── send-broken-write.json │ ├── receive-client-timeout.json │ ├── send-failed-write.json │ ├── close-remote.json │ ├── send-receive-128.json │ ├── client.connect-invalid-upgrade.json │ ├── client.connect-handshake-failure.json │ ├── send-receive.json │ ├── client.connect-invalid-key.json │ ├── server.close.json │ ├── receive-empty-read.json │ ├── receive-broken-read.json │ ├── client.connect-context.json │ ├── client.connect-authed.json │ ├── client.connect-timeout.json │ ├── client.close.json │ ├── client.connect-root.json │ ├── client.connect-default-port-ws.json │ ├── client.connect-default-port-wss.json │ ├── client.connect-extended.json │ ├── client.connect.json │ ├── client.connect-persistent.json │ ├── send-convenicance.json │ ├── client.connect-handshake-error.json │ ├── client.reconnect.json │ ├── send-receive-multi-fragment.json │ ├── send-receive-65536.json │ ├── client.connect-headers.json │ ├── receive-fragmentation.json │ ├── ping-pong.json │ ├── server.accept-failed-http.json │ ├── server.accept-failed-ws-key.json │ ├── server.accept.json │ ├── server.accept-timeout.json │ └── server.accept-destruct.json ├── README.md ├── ExceptionTest.php └── MessageTest.php ├── .github ├── ISSUE_TEMPLATE │ ├── other-issue.md │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── acceptance.yml ├── phpunit.xml.dist ├── Makefile ├── COPYING.md ├── composer.json ├── docs ├── Contributing.md ├── Message.md ├── Examples.md ├── Changelog.md ├── Server.md └── Client.md ├── examples ├── send.php ├── echoserver.php ├── random_server.php └── random_client.php └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .phpunit.result.cache 3 | build/ 4 | composer.lock 5 | composer.phar 6 | vendor/ -------------------------------------------------------------------------------- /lib/Exception.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | lib/ 6 | 7 | 8 | 9 | 10 | tests 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tests/scripts/server.disconnect.json: -------------------------------------------------------------------------------- 1 | [ 2 | 3 | { 4 | "function": "get_resource_type", 5 | "params": [ 6 | "@mock-stream" 7 | ], 8 | "return": "stream" 9 | }, 10 | { 11 | "function": "fclose", 12 | "params": [ 13 | "@mock-stream" 14 | ], 15 | "return": true 16 | }, 17 | { 18 | "function": "get_resource_type", 19 | "params": [ 20 | "@mock-stream" 21 | ], 22 | "return": "" 23 | } 24 | ] -------------------------------------------------------------------------------- /tests/scripts/config-timeout.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "get_resource_type", 4 | "params": [ 5 | "@mock-stream" 6 | ], 7 | "return": "stream" 8 | }, 9 | { 10 | "function": "stream_set_timeout", 11 | "params": [ 12 | "@mock-stream", 13 | 300 14 | ], 15 | "return": true 16 | }, 17 | { 18 | "function": "get_resource_type", 19 | "params": [ 20 | "@mock-stream" 21 | ], 22 | "return": "stream" 23 | } 24 | ] -------------------------------------------------------------------------------- /tests/scripts/client.connect-error.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "tcp:\/\/localhost:8000", 11 | null, 12 | null, 13 | 5, 14 | 4, 15 | "@mock-stream-context" 16 | ], 17 | "error": { 18 | "msg": "A PHP error", 19 | "type": 512 20 | }, 21 | "return": false 22 | } 23 | ] -------------------------------------------------------------------------------- /lib/OpcodeTrait.php: -------------------------------------------------------------------------------- 1 | 0, 16 | 'text' => 1, 17 | 'binary' => 2, 18 | 'close' => 8, 19 | 'ping' => 9, 20 | 'pong' => 10, 21 | ]; 22 | } 23 | -------------------------------------------------------------------------------- /tests/scripts/client.connect-bad-stream.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "tcp:\/\/localhost:8000", 11 | null, 12 | null, 13 | 5, 14 | 4, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "bad stream" 25 | } 26 | ] -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/scripts/server.construct-error-socket-server.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_socket_server", 4 | "params": [ 5 | "tcp://0.0.0.0:9999", 6 | null, 7 | null 8 | ], 9 | "error": { 10 | "msg": "A PHP error", 11 | "type": 512 12 | }, 13 | "return": false 14 | }, 15 | { 16 | "function": "stream_socket_server", 17 | "params": [ 18 | "tcp://0.0.0.0:10000", 19 | null, 20 | null 21 | ], 22 | "error": { 23 | "msg": "A PHP error", 24 | "type": 512 25 | }, 26 | "return": false 27 | } 28 | ] -------------------------------------------------------------------------------- /tests/scripts/server.accept-failed-handshake.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_socket_accept", 4 | "params": [ 5 | "@mock-socket" 6 | ], 7 | "return": "@mock-stream" 8 | }, 9 | { 10 | "function": "stream_socket_get_name", 11 | "params": [ 12 | "@mock-stream" 13 | ], 14 | "return": "127.0.0.1:12345" 15 | }, 16 | { 17 | "function": "stream_get_line", 18 | "params": [ 19 | "@mock-stream", 20 | 1024, 21 | "\r\n" 22 | ], 23 | "return": false 24 | }, 25 | { 26 | "function": "get_resource_type", 27 | "params": [ 28 | "@mock-stream" 29 | ], 30 | "return": "" 31 | } 32 | ] -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/scripts/client.connect-persistent-failure.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "tcp:\/\/localhost:8000", 11 | null, 12 | null, 13 | 5, 14 | 5, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "persistent stream" 25 | }, 26 | { 27 | "function": "ftell", 28 | "params": [ 29 | "@mock-stream" 30 | ], 31 | "return": false 32 | } 33 | ] 34 | 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/ConnectionException.php: -------------------------------------------------------------------------------- 1 | data = $data; 27 | } 28 | 29 | public function getData(): array 30 | { 31 | return $this->data; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/Message/Factory.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/scripts/send-receive-128.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "get_resource_type", 4 | "params": [ 5 | "@mock-stream" 6 | ], 7 | "return": "stream" 8 | }, 9 | { 10 | "function": "fwrite", 11 | "params": [ 12 | "@mock-stream" 13 | ], 14 | "return": 132 15 | }, 16 | { 17 | "function": "get_resource_type", 18 | "params": [ 19 | "@mock-stream" 20 | ], 21 | "return": "stream" 22 | }, 23 | { 24 | "function": "fread", 25 | "params": [ 26 | "@mock-stream", 27 | 2 28 | ], 29 | "return-op": "chr-array", 30 | "return": [129, 126] 31 | }, 32 | { 33 | "function": "fread", 34 | "params": [ 35 | "@mock-stream", 36 | 2 37 | ], 38 | "return-op": "chr-array", 39 | "return": [0, 128] 40 | }, 41 | { 42 | "function": "fread", 43 | "params": [ 44 | "@mock-stream", 45 | 128 46 | ], 47 | "return-op": "file", 48 | "return": ["payload.128.txt", 0, 132] 49 | } 50 | ] -------------------------------------------------------------------------------- /tests/scripts/client.connect-invalid-upgrade.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "tcp:\/\/localhost:8000", 11 | null, 12 | null, 13 | 5, 14 | 4, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "stream" 25 | }, 26 | { 27 | "function": "stream_set_timeout", 28 | "params": [ 29 | "@mock-stream", 30 | 5 31 | ], 32 | "return": true 33 | }, 34 | { 35 | "function": "fwrite", 36 | "params": [ 37 | "@mock-stream" 38 | ], 39 | "return": 199 40 | }, 41 | { 42 | "function": "fgets", 43 | "params": [ 44 | "@mock-stream", 45 | 1024 46 | ], 47 | "return": "Invalid upgrade response\r\n\r\n" 48 | } 49 | ] -------------------------------------------------------------------------------- /tests/scripts/client.connect-handshake-failure.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "tcp:\/\/localhost:8000", 11 | null, 12 | null, 13 | 5, 14 | 4, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "stream" 25 | }, 26 | { 27 | "function": "stream_set_timeout", 28 | "params": [ 29 | "@mock-stream", 30 | 5 31 | ], 32 | "return": true 33 | }, 34 | { 35 | "function": "fwrite", 36 | "params": [ 37 | "@mock-stream" 38 | ], 39 | "return-op": "key-save", 40 | "return": 199 41 | }, 42 | { 43 | "function": "fgets", 44 | "params": [ 45 | "@mock-stream", 46 | 1024 47 | ], 48 | "return": false 49 | } 50 | ] -------------------------------------------------------------------------------- /tests/scripts/send-receive.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "get_resource_type", 4 | "params": [ 5 | "@mock-stream" 6 | ], 7 | "return": "stream" 8 | }, 9 | { 10 | "function": "fwrite", 11 | "params": [ 12 | "@mock-stream" 13 | ], 14 | "return": 23 15 | }, 16 | { 17 | "function": "get_resource_type", 18 | "params": [ 19 | "@mock-stream" 20 | ], 21 | "return": "stream" 22 | }, 23 | { 24 | "function": "fread", 25 | "params": [ 26 | "@mock-stream", 27 | 2 28 | ], 29 | "return-op": "chr-array", 30 | "return": [129, 147] 31 | }, 32 | { 33 | "function": "fread", 34 | "params": [ 35 | "@mock-stream", 36 | 4 37 | ], 38 | "return-op": "chr-array", 39 | "return": [33, 111, 149, 174] 40 | }, 41 | { 42 | "function": "fread", 43 | "params": [ 44 | "@mock-stream", 45 | 19 46 | ], 47 | "return-op": "chr-array", 48 | "return": [115, 10, 246, 203, 72, 25, 252, 192, 70, 79, 244, 142, 76, 10, 230, 221, 64, 8, 240] 49 | } 50 | ] -------------------------------------------------------------------------------- /tests/scripts/client.connect-invalid-key.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "tcp:\/\/localhost:8000", 11 | null, 12 | null, 13 | 5, 14 | 4, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "stream" 25 | }, 26 | { 27 | "function": "stream_set_timeout", 28 | "params": [ 29 | "@mock-stream", 30 | 5 31 | ], 32 | "return": true 33 | }, 34 | { 35 | "function": "fwrite", 36 | "params": [ 37 | "@mock-stream" 38 | ], 39 | "return": 199 40 | }, 41 | { 42 | "function": "fgets", 43 | "params": [ 44 | "@mock-stream", 45 | 1024 46 | ], 47 | "return": "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: BAD\r\n\r\n" 48 | } 49 | ] -------------------------------------------------------------------------------- /tests/scripts/server.close.json: -------------------------------------------------------------------------------- 1 | [ 2 | 3 | { 4 | "function": "get_resource_type", 5 | "params": [ 6 | "@mock-stream" 7 | ], 8 | "return": "stream" 9 | }, 10 | { 11 | "function": "get_resource_type", 12 | "params": [ 13 | "@mock-stream" 14 | ], 15 | "return": "stream" 16 | }, 17 | { 18 | "function": "fwrite", 19 | "params": [], 20 | "return":12 21 | }, 22 | { 23 | "function": "fread", 24 | "params": [ 25 | "@mock-stream", 26 | 2 27 | ], 28 | "return-op": "chr-array", 29 | "return": [136,154] 30 | }, 31 | { 32 | "function": "fread", 33 | "params": [ 34 | "@mock-stream", 35 | 4 36 | ], 37 | "return-op": "chr-array", 38 | "return": [245,55,62,8] 39 | }, 40 | { 41 | "function": "fread", 42 | "params": [ 43 | "@mock-stream", 44 | 26 45 | ], 46 | "return-op": "chr-array", 47 | "return": [246,223,125,100,154,68,91,40,148,84,85,102,154,64,82,109,145,80,91,108,207,23,15,56,197,7] 48 | }, 49 | { 50 | "function": "fclose", 51 | "params": [ 52 | "@mock-stream" 53 | ], 54 | "return": true 55 | } 56 | ] -------------------------------------------------------------------------------- /tests/scripts/receive-empty-read.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "get_resource_type", 4 | "params": [ 5 | "@mock-stream" 6 | ], 7 | "return": "stream" 8 | }, 9 | { 10 | "function": "fread", 11 | "params": [], 12 | "return": "" 13 | }, 14 | { 15 | "function": "stream_get_meta_data", 16 | "params": [ 17 | "@mock-stream" 18 | ], 19 | "return": { 20 | "timed_out": false, 21 | "blocked": true, 22 | "eof": false, 23 | "stream_type": "tcp_socket\/ssl", 24 | "mode": "r+", 25 | "unread_bytes": 0, 26 | "seekable": false 27 | } 28 | }, 29 | { 30 | "function": "get_resource_type", 31 | "params": [ 32 | "@mock-stream" 33 | ], 34 | "return": "stream" 35 | }, 36 | { 37 | "function": "stream_get_meta_data", 38 | "params": [ 39 | "@mock-stream" 40 | ], 41 | "return": { 42 | "timed_out": true, 43 | "blocked": true, 44 | "eof": false, 45 | "stream_type": "tcp_socket\/ssl", 46 | "mode": "r+", 47 | "unread_bytes": 2, 48 | "seekable": false 49 | } 50 | }, 51 | { 52 | "function": "fclose", 53 | "params": [ 54 | "@mock-stream" 55 | ], 56 | "return":true 57 | } 58 | ] -------------------------------------------------------------------------------- /tests/scripts/receive-broken-read.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "get_resource_type", 4 | "params": [ 5 | "@mock-stream" 6 | ], 7 | "return": "stream" 8 | }, 9 | { 10 | "function": "fread", 11 | "params": [], 12 | "return": false 13 | }, 14 | { 15 | "function": "stream_get_meta_data", 16 | "params": [ 17 | "@mock-stream" 18 | ], 19 | "return": { 20 | "timed_out": false, 21 | "blocked": true, 22 | "eof": true, 23 | "stream_type": "tcp_socket\/ssl", 24 | "mode": "r+", 25 | "unread_bytes": 2, 26 | "seekable": false 27 | } 28 | }, 29 | { 30 | "function": "get_resource_type", 31 | "params": [ 32 | "@mock-stream" 33 | ], 34 | "return": "stream" 35 | }, 36 | { 37 | "function": "stream_get_meta_data", 38 | "params": [ 39 | "@mock-stream" 40 | ], 41 | "return": { 42 | "timed_out": false, 43 | "blocked": true, 44 | "eof": true, 45 | "stream_type": "tcp_socket\/ssl", 46 | "mode": "r+", 47 | "unread_bytes": 2, 48 | "seekable": false 49 | } 50 | }, 51 | { 52 | "function": "fclose", 53 | "params": [ 54 | "@mock-stream" 55 | ], 56 | "return":true 57 | } 58 | ] 59 | -------------------------------------------------------------------------------- /tests/scripts/client.connect-context.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "get_resource_type", 4 | "params": [], 5 | "return": "stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "tcp:\/\/localhost:8000", 11 | null, 12 | null, 13 | 5, 14 | 4, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "stream" 25 | }, 26 | { 27 | "function": "stream_set_timeout", 28 | "params": [ 29 | "@mock-stream", 30 | 5 31 | ], 32 | "return": true 33 | }, 34 | { 35 | "function": "fwrite", 36 | "params": [ 37 | "@mock-stream" 38 | ], 39 | "return-op": "key-save", 40 | "return": 199 41 | }, 42 | { 43 | "function": "fgets", 44 | "params": [ 45 | "@mock-stream", 46 | 1024 47 | ], 48 | "return-op": "key-respond", 49 | "return": "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {key}\r\n\r\n" 50 | }, 51 | { 52 | "function": "fwrite", 53 | "params": [ 54 | "@mock-stream" 55 | ], 56 | "return": 13 57 | } 58 | ] -------------------------------------------------------------------------------- /tests/scripts/client.connect-authed.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "ssl:\/\/localhost:8000", 11 | null, 12 | null, 13 | 5, 14 | 4, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "stream" 25 | }, 26 | { 27 | "function": "stream_set_timeout", 28 | "params": [ 29 | "@mock-stream", 30 | 5 31 | ], 32 | "return": true 33 | }, 34 | { 35 | "function": "fwrite", 36 | "params": [ 37 | "@mock-stream" 38 | ], 39 | "return-op": "key-save", 40 | "return": 248 41 | }, 42 | { 43 | "function": "fgets", 44 | "params": [ 45 | "@mock-stream", 46 | 1024 47 | ], 48 | "return-op": "key-respond", 49 | "return": "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {key}\r\n\r\n" 50 | }, 51 | { 52 | "function": "fwrite", 53 | "params": [ 54 | "@mock-stream" 55 | ], 56 | "return": 13 57 | } 58 | ] -------------------------------------------------------------------------------- /tests/scripts/client.connect-timeout.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "tcp:\/\/localhost:8000", 11 | null, 12 | null, 13 | 300, 14 | 4, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "stream" 25 | }, 26 | { 27 | "function": "stream_set_timeout", 28 | "params": [ 29 | "@mock-stream", 30 | 300 31 | ], 32 | "return": true 33 | }, 34 | { 35 | "function": "fwrite", 36 | "params": [ 37 | "@mock-stream" 38 | ], 39 | "return-op": "key-save", 40 | "return": 199 41 | }, 42 | { 43 | "function": "fgets", 44 | "params": [ 45 | "@mock-stream", 46 | 1024 47 | ], 48 | "return-op": "key-respond", 49 | "return": "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {key}\r\n\r\n" 50 | }, 51 | { 52 | "function": "fwrite", 53 | "params": [ 54 | "@mock-stream" 55 | ], 56 | "return": 13 57 | } 58 | ] -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/scripts/client.close.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "get_resource_type", 4 | "params": [ 5 | "@mock-stream" 6 | ], 7 | "return": "stream" 8 | }, 9 | { 10 | "function": "get_resource_type", 11 | "params": [ 12 | "@mock-stream" 13 | ], 14 | "return": "stream" 15 | }, 16 | { 17 | "function": "get_resource_type", 18 | "params": [ 19 | "@mock-stream" 20 | ], 21 | "return": "stream" 22 | }, 23 | { 24 | "function": "fwrite", 25 | "params": [], 26 | "return": 12 27 | }, 28 | { 29 | "function": "fread", 30 | "params": [ 31 | "@mock-stream", 32 | 2 33 | ], 34 | "return-op": "chr-array", 35 | "return":[136, 154] 36 | }, 37 | { 38 | "function": "fread", 39 | "params": [ 40 | "@mock-stream", 41 | 4 42 | ], 43 | "return-op": "chr-array", 44 | "return":[98, 250, 210, 113] 45 | }, 46 | { 47 | "function": "fread", 48 | "params": [ 49 | "@mock-stream", 50 | 26 51 | ], 52 | "return-op": "chr-array", 53 | "return": [97, 18, 145, 29, 13, 137, 183, 81, 3, 153, 185, 31, 13, 141, 190, 20, 6, 157, 183, 21, 88, 218, 227, 65, 82, 202] 54 | }, 55 | { 56 | "function": "fclose", 57 | "params": [ 58 | "@mock-stream" 59 | ], 60 | "return":true 61 | } 62 | ] -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/scripts/client.connect-root.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "tcp:\/\/localhost:8000", 11 | null, 12 | null, 13 | 5, 14 | 4, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "stream" 25 | }, 26 | { 27 | "function": "stream_set_timeout", 28 | "params": [ 29 | "@mock-stream", 30 | 5 31 | ], 32 | "return": true 33 | }, 34 | { 35 | "function": "fwrite", 36 | "params": [ 37 | "@mock-stream", 38 | "GET / HTTP/1.1\r\nHost: localhost:8000\r\nUser-Agent: websocket-client-php\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n" 39 | ], 40 | "input-op": "key-save", 41 | "return": 224 42 | }, 43 | { 44 | "function": "fgets", 45 | "params": [ 46 | "@mock-stream", 47 | 1024 48 | ], 49 | "return-op": "key-respond", 50 | "return": "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {key}\r\n\r\n" 51 | }, 52 | { 53 | "function": "fwrite", 54 | "params": [ 55 | "@mock-stream" 56 | ], 57 | "return": 13 58 | } 59 | ] -------------------------------------------------------------------------------- /tests/scripts/client.connect-default-port-ws.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "tcp:\/\/localhost:80", 11 | null, 12 | null, 13 | 5, 14 | 4, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "stream" 25 | }, 26 | { 27 | "function": "stream_set_timeout", 28 | "params": [ 29 | "@mock-stream", 30 | 5 31 | ], 32 | "return": true 33 | }, 34 | { 35 | "function": "fwrite", 36 | "params": [ 37 | "@mock-stream", 38 | "GET /my/mock/path HTTP/1.1\r\nHost: localhost:80\r\nUser-Agent: websocket-client-php\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n" 39 | ], 40 | "input-op": "key-save", 41 | "return": 224 42 | }, 43 | { 44 | "function": "fgets", 45 | "params": [ 46 | "@mock-stream", 47 | 1024 48 | ], 49 | "return-op": "key-respond", 50 | "return": "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {key}\r\n\r\n" 51 | }, 52 | { 53 | "function": "fwrite", 54 | "params": [ 55 | "@mock-stream" 56 | ], 57 | "return": 13 58 | } 59 | ] -------------------------------------------------------------------------------- /tests/scripts/client.connect-default-port-wss.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "ssl:\/\/localhost:443", 11 | null, 12 | null, 13 | 5, 14 | 4, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "stream" 25 | }, 26 | { 27 | "function": "stream_set_timeout", 28 | "params": [ 29 | "@mock-stream", 30 | 5 31 | ], 32 | "return": true 33 | }, 34 | { 35 | "function": "fwrite", 36 | "params": [ 37 | "@mock-stream", 38 | "GET /my/mock/path HTTP/1.1\r\nHost: localhost:443\r\nUser-Agent: websocket-client-php\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n" 39 | ], 40 | "input-op": "key-save", 41 | "return": 224 42 | }, 43 | { 44 | "function": "fgets", 45 | "params": [ 46 | "@mock-stream", 47 | 1024 48 | ], 49 | "return-op": "key-respond", 50 | "return": "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {key}\r\n\r\n" 51 | }, 52 | { 53 | "function": "fwrite", 54 | "params": [ 55 | "@mock-stream" 56 | ], 57 | "return": 13 58 | } 59 | ] -------------------------------------------------------------------------------- /tests/scripts/client.connect-extended.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "tcp:\/\/localhost:8000", 11 | null, 12 | null, 13 | 5, 14 | 4, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "stream" 25 | }, 26 | { 27 | "function": "stream_set_timeout", 28 | "params": [ 29 | "@mock-stream", 30 | 5 31 | ], 32 | "return": true 33 | }, 34 | { 35 | "function": "fwrite", 36 | "params": [ 37 | "@mock-stream", 38 | "GET /my/mock/path?my_query=yes HTTP/1.1\r\nHost: localhost:8000\r\nUser-Agent: websocket-client-php\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n" 39 | ], 40 | "input-op": "key-save", 41 | "return": 224 42 | }, 43 | { 44 | "function": "fgets", 45 | "params": [ 46 | "@mock-stream", 47 | 1024 48 | ], 49 | "return-op": "key-respond", 50 | "return": "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {key}\r\n\r\n" 51 | }, 52 | { 53 | "function": "fwrite", 54 | "params": [ 55 | "@mock-stream" 56 | ], 57 | "return": 13 58 | } 59 | ] -------------------------------------------------------------------------------- /tests/scripts/client.connect.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "tcp:\/\/localhost:8000", 11 | null, 12 | null, 13 | 5, 14 | 4, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "stream" 25 | }, 26 | { 27 | "function": "stream_set_timeout", 28 | "params": [ 29 | "@mock-stream", 30 | 5 31 | ], 32 | "return": true 33 | }, 34 | { 35 | "function": "fwrite", 36 | "regexp": true, 37 | "params": [ 38 | "@mock-stream", 39 | "GET /my/mock/path HTTP/1.1\r\nHost: localhost:8000\r\nUser-Agent: websocket-client-php\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n" 40 | ], 41 | "input-op": "key-save", 42 | "return": 199 43 | }, 44 | { 45 | "function": "fgets", 46 | "params": [ 47 | "@mock-stream", 48 | 1024 49 | ], 50 | "return-op": "key-respond", 51 | "return": "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {key}\r\n\r\n" 52 | }, 53 | { 54 | "function": "fwrite", 55 | "params": [ 56 | "@mock-stream" 57 | ], 58 | "return": 13 59 | } 60 | ] -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/Message/Message.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 | -------------------------------------------------------------------------------- /tests/scripts/client.connect-persistent.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "tcp:\/\/localhost:8000", 11 | null, 12 | null, 13 | 5, 14 | 5, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "persistent stream" 25 | }, 26 | { 27 | "function": "ftell", 28 | "params": [ 29 | "@mock-stream" 30 | ], 31 | "return": 0 32 | }, 33 | { 34 | "function": "stream_set_timeout", 35 | "params": [ 36 | "@mock-stream", 37 | 5 38 | ], 39 | "return": true 40 | }, 41 | { 42 | "function": "fwrite", 43 | "params": [ 44 | "@mock-stream" 45 | ], 46 | "return-op": "key-save", 47 | "return": 248 48 | }, 49 | { 50 | "function": "fgets", 51 | "params": [ 52 | "@mock-stream", 53 | 1024 54 | ], 55 | "return-op": "key-respond", 56 | "return": "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {key}\r\n\r\n" 57 | }, 58 | { 59 | "function": "fwrite", 60 | "params": [ 61 | "@mock-stream" 62 | ], 63 | "return": 13 64 | }, 65 | { 66 | "function": "get_resource_type", 67 | "params": [ 68 | "@mock-stream" 69 | ], 70 | "return": "persistent stream" 71 | }, 72 | { 73 | "function": "fclose", 74 | "params": [ 75 | "@mock-stream" 76 | ], 77 | "return":true 78 | } 79 | ] 80 | 81 | -------------------------------------------------------------------------------- /tests/scripts/send-convenicance.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "get_resource_type", 4 | "params": [ 5 | "@mock-stream" 6 | ], 7 | "return": "stream" 8 | }, 9 | { 10 | "function": "fwrite", 11 | "params": [ 12 | "@mock-stream" 13 | ], 14 | "return": 26 15 | }, 16 | { 17 | "function": "get_resource_type", 18 | "params": [ 19 | "@mock-stream" 20 | ], 21 | "return": "stream" 22 | }, 23 | { 24 | "function": "fwrite", 25 | "params": [ 26 | "@mock-stream" 27 | ], 28 | "return": 6 29 | }, 30 | { 31 | "function": "get_resource_type", 32 | "params": [ 33 | "@mock-stream" 34 | ], 35 | "return": "stream" 36 | }, 37 | { 38 | "function": "fwrite", 39 | "params": [ 40 | "@mock-stream" 41 | ], 42 | "return": 6 43 | }, 44 | { 45 | "function": "get_resource_type", 46 | "params": [ 47 | "@mock-stream" 48 | ], 49 | "return": "stream" 50 | }, 51 | { 52 | "function": "stream_socket_get_name", 53 | "params": [ 54 | "@mock-stream" 55 | ], 56 | "return": "127.0.0.1:12345" 57 | }, 58 | { 59 | "function": "get_resource_type", 60 | "params": [ 61 | "@mock-stream" 62 | ], 63 | "return": "stream" 64 | }, 65 | { 66 | "function": "stream_socket_get_name", 67 | "params": [ 68 | "@mock-stream" 69 | ], 70 | "return": "127.0.0.1:8000" 71 | }, 72 | { 73 | "function": "get_resource_type", 74 | "params": [ 75 | "@mock-stream" 76 | ], 77 | "return": "stream" 78 | }, 79 | { 80 | "function": "stream_socket_get_name", 81 | "params": [ 82 | "@mock-stream" 83 | ], 84 | "return": "127.0.0.1:12345" 85 | } 86 | ] -------------------------------------------------------------------------------- /tests/scripts/client.connect-handshake-error.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "tcp:\/\/localhost:8000", 11 | null, 12 | null, 13 | 5, 14 | 4, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "stream" 25 | }, 26 | { 27 | "function": "stream_set_timeout", 28 | "params": [ 29 | "@mock-stream", 30 | 5 31 | ], 32 | "return": true 33 | }, 34 | { 35 | "function": "fwrite", 36 | "params": [ 37 | "@mock-stream" 38 | ], 39 | "return-op": "key-save", 40 | "return": 199 41 | }, 42 | { 43 | "function": "fgets", 44 | "params": [ 45 | "@mock-stream", 46 | 1024 47 | ], 48 | "return": false 49 | }, 50 | { 51 | "function": "get_resource_type", 52 | "params": [ 53 | "@mock-stream" 54 | ], 55 | "return": "stream" 56 | }, 57 | { 58 | "function": "stream_get_meta_data", 59 | "params": [ 60 | "@mock-stream" 61 | ], 62 | "return": { 63 | "timed_out": true, 64 | "blocked": true, 65 | "eof": false, 66 | "stream_type": "tcp_socket\/ssl", 67 | "mode": "r+", 68 | "unread_bytes": 0, 69 | "seekable": false 70 | } 71 | }, 72 | { 73 | "function": "fclose", 74 | "params": [ 75 | "@mock-stream" 76 | ], 77 | "return": true 78 | }, 79 | { 80 | "function": "get_resource_type", 81 | "params": [ 82 | "@mock-stream" 83 | ], 84 | "return": "" 85 | } 86 | ] -------------------------------------------------------------------------------- /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/mock/mock-socket.php: -------------------------------------------------------------------------------- 1 | 6 | You won't be able to submit new issues or pull requests, and no additional features will be added 7 | 8 | This library has been replaced by [sirn-se/websocket-php](https://github.com/sirn-se/websocket-php) 9 | 10 | ## Websocket Client and Server for PHP 11 | 12 | This library contains WebSocket client and server for PHP. 13 | 14 | The client and server provides methods for reading and writing to WebSocket streams. 15 | It does not include convenience operations such as listeners and implicit error handling. 16 | 17 | ## Documentation 18 | 19 | - [Client](docs/Client.md) 20 | - [Server](docs/Server.md) 21 | - [Examples](docs/Examples.md) 22 | - [Changelog](docs/Changelog.md) 23 | - [Contributing](docs/Contributing.md) 24 | 25 | ## Installing 26 | 27 | Preferred way to install is with [Composer](https://getcomposer.org/). 28 | ``` 29 | composer require textalk/websocket 30 | ``` 31 | 32 | * Current version support PHP versions `^7.4|^8.0`. 33 | * For PHP `7.2` and `7.3` support use version [`1.5`](https://github.com/Textalk/websocket-php/tree/1.5.0). 34 | * For PHP `7.1` support use version [`1.4`](https://github.com/Textalk/websocket-php/tree/1.4.0). 35 | * For PHP `^5.4` and `7.0` support use version [`1.3`](https://github.com/Textalk/websocket-php/tree/1.3.0). 36 | 37 | ## Client 38 | 39 | The [client](docs/Client.md) can read and write on a WebSocket stream. 40 | It internally supports Upgrade handshake and implicit close and ping/pong operations. 41 | 42 | ```php 43 | $client = new WebSocket\Client("ws://echo.websocket.org/"); 44 | $client->text("Hello WebSocket.org!"); 45 | echo $client->receive(); 46 | $client->close(); 47 | ``` 48 | 49 | ## Server 50 | 51 | The library contains a rudimentary single stream/single thread [server](docs/Server.md). 52 | It internally supports Upgrade handshake and implicit close and ping/pong operations. 53 | 54 | Note that it does **not** support threading or automatic association ot continuous client requests. 55 | If you require this kind of server behavior, you need to build it on top of provided server implementation. 56 | 57 | ```php 58 | $server = new WebSocket\Server(); 59 | $server->accept(); 60 | $message = $server->receive(); 61 | $server->text($message); 62 | $server->close(); 63 | ``` 64 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /tests/scripts/client.reconnect.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "get_resource_type", 4 | "params": [ 5 | "@mock-stream" 6 | ], 7 | "return": "unknown" 8 | }, 9 | { 10 | "function": "get_resource_type", 11 | "params": [ 12 | "@mock-stream" 13 | ], 14 | "return": "unknown" 15 | }, 16 | { 17 | "function": "stream_context_create", 18 | "params": [], 19 | "return": "@mock-stream-context" 20 | }, 21 | { 22 | "function": "stream_socket_client", 23 | "params": [ 24 | "tcp:\/\/localhost:8000", 25 | null, 26 | null, 27 | 5, 28 | 4, 29 | "@mock-stream-context" 30 | ], 31 | "return": "@mock-stream" 32 | }, 33 | { 34 | "function": "get_resource_type", 35 | "params": [ 36 | "@mock-stream" 37 | ], 38 | "return": "stream" 39 | }, 40 | { 41 | "function": "stream_set_timeout", 42 | "params": [ 43 | "@mock-stream", 44 | 5 45 | ], 46 | "return": true 47 | }, 48 | { 49 | "function": "fwrite", 50 | "params": [ 51 | "@mock-stream" 52 | ], 53 | "return-op": "key-save", 54 | "return": 199 55 | }, 56 | { 57 | "function": "fgets", 58 | "params": [ 59 | "@mock-stream", 60 | 1024 61 | ], 62 | "return-op": "key-respond", 63 | "return": "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {key}\r\n\r\n" 64 | }, 65 | { 66 | "function": "fread", 67 | "params": [ 68 | "@mock-stream", 69 | 2 70 | ], 71 | "return-op": "chr-array", 72 | "return": [129, 147] 73 | }, 74 | { 75 | "function": "fread", 76 | "params": [ 77 | "@mock-stream", 78 | 4 79 | ], 80 | "return-op": "chr-array", 81 | "return": [33, 111, 149, 174] 82 | }, 83 | { 84 | "function": "fread", 85 | "params": [ 86 | "@mock-stream", 87 | 19 88 | ], 89 | "return-op": "chr-array", 90 | "return": [115, 10, 246, 203, 72, 25, 252, 192, 70, 79, 244, 142, 76, 10, 230, 221, 64, 8, 240] 91 | }, 92 | { 93 | "function": "get_resource_type", 94 | "params": [ 95 | "@mock-stream" 96 | ], 97 | "return": "stream" 98 | } 99 | ] -------------------------------------------------------------------------------- /tests/scripts/send-receive-multi-fragment.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "get_resource_type", 4 | "params": [ 5 | "@mock-stream" 6 | ], 7 | "return": "stream" 8 | }, 9 | { 10 | "function": "fwrite", 11 | "params": [], 12 | "return": 14 13 | }, 14 | { 15 | "function": "fwrite", 16 | "params": [], 17 | "return": 14 18 | }, 19 | { 20 | "function": "fwrite", 21 | "params": [], 22 | "return": 9 23 | }, 24 | { 25 | "function": "get_resource_type", 26 | "params": [ 27 | "@mock-stream" 28 | ], 29 | "return": "stream" 30 | }, 31 | { 32 | "function": "fread", 33 | "params": [ 34 | "@mock-stream", 35 | 2 36 | ], 37 | "return-op": "chr-array", 38 | "return": [1, 136] 39 | }, 40 | { 41 | "function": "fread", 42 | "params": [ 43 | "@mock-stream", 44 | 4 45 | ], 46 | "return-op": "chr-array", 47 | "return": [105, 29, 187, 18] 48 | }, 49 | { 50 | "function": "fread", 51 | "params": [ 52 | "@mock-stream", 53 | 8 54 | ], 55 | "return-op": "chr-array", 56 | "return": [36, 104, 215, 102, 0, 61, 221, 96] 57 | }, 58 | { 59 | "function": "fread", 60 | "params": [ 61 | "@mock-stream", 62 | 2 63 | ], 64 | "return-op": "chr-array", 65 | "return": [0, 136] 66 | }, 67 | { 68 | "function": "fread", 69 | "params": [ 70 | "@mock-stream", 71 | 4 72 | ], 73 | "return-op": "chr-array", 74 | "return": [221, 240, 46, 69] 75 | }, 76 | { 77 | "function": "fread", 78 | "params": [ 79 | "@mock-stream", 80 | 8 81 | ], 82 | "return-op": "chr-array", 83 | "return": [188, 151, 67, 32, 179, 132, 14, 49] 84 | }, 85 | { 86 | "function": "fread", 87 | "params": [ 88 | "@mock-stream", 89 | 2 90 | ], 91 | "return-op": "chr-array", 92 | "return": [128, 131] 93 | }, 94 | { 95 | "function": "fread", 96 | "params": [ 97 | "@mock-stream", 98 | 4 99 | ], 100 | "return-op": "chr-array", 101 | "return": [9, 60, 117, 193] 102 | }, 103 | { 104 | "function": "fread", 105 | "params": [ 106 | "@mock-stream", 107 | 3 108 | ], 109 | "return-op": "chr-array", 110 | "return": [108, 79, 1] 111 | } 112 | ] -------------------------------------------------------------------------------- /tests/scripts/send-receive-65536.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "get_resource_type", 4 | "params": [ 5 | "@mock-stream" 6 | ], 7 | "return": "stream" 8 | }, 9 | { 10 | "function": "fwrite", 11 | "params": [ 12 | "@mock-stream" 13 | ], 14 | "return": 65546 15 | }, 16 | { 17 | "function": "get_resource_type", 18 | "params": [ 19 | "@mock-stream" 20 | ], 21 | "return": "stream" 22 | }, 23 | { 24 | "function": "fread", 25 | "params": [ 26 | "@mock-stream", 27 | 2 28 | ], 29 | "return-op": "chr-array", 30 | "return": [129, 127] 31 | }, 32 | { 33 | "function": "fread", 34 | "params": [ 35 | "@mock-stream", 36 | 8 37 | ], 38 | "return-op": "chr-array", 39 | "return": [0, 0, 0, 0, 0, 1, 0, 0] 40 | }, 41 | { 42 | "function": "fread", 43 | "params": [ 44 | "@mock-stream", 45 | 65536 46 | ], 47 | "return-op": "file", 48 | "return": ["payload.65536.txt", 0, 16374] 49 | }, 50 | { 51 | "function": "fread", 52 | "params": [ 53 | "@mock-stream", 54 | 49162 55 | ], 56 | "return-op": "file", 57 | "return": ["payload.65536.txt", 16374, 8192] 58 | }, 59 | { 60 | "function": "fread", 61 | "params": [ 62 | "@mock-stream", 63 | 40970 64 | ], 65 | "return-op": "file", 66 | "return": ["payload.65536.txt", 24566, 8192] 67 | }, 68 | { 69 | "function": "fread", 70 | "params": [ 71 | "@mock-stream", 72 | 32778 73 | ], 74 | "return-op": "file", 75 | "return": ["payload.65536.txt", 32758, 8192] 76 | }, 77 | { 78 | "function": "fread", 79 | "params": [ 80 | "@mock-stream", 81 | 24586 82 | ], 83 | "return-op": "file", 84 | "return": ["payload.65536.txt", 40950, 8192] 85 | }, 86 | { 87 | "function": "fread", 88 | "params": [ 89 | "@mock-stream", 90 | 16394 91 | ], 92 | "return-op": "file", 93 | "return": ["payload.65536.txt", 49142, 8192] 94 | }, 95 | { 96 | "function": "fread", 97 | "params": [ 98 | "@mock-stream", 99 | 8202 100 | ], 101 | "return-op": "file", 102 | "return": ["payload.65536.txt", 57334, 8192] 103 | }, 104 | { 105 | "function": "fread", 106 | "params": [ 107 | "@mock-stream", 108 | 10 109 | ], 110 | "return-op": "file", 111 | "return": ["payload.65536.txt", 65526, 10] 112 | } 113 | ] -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/scripts/client.connect-headers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_context_create", 4 | "params": [], 5 | "return": "@mock-stream-context" 6 | }, 7 | { 8 | "function": "stream_socket_client", 9 | "params": [ 10 | "tcp:\/\/localhost:8000", 11 | null, 12 | null, 13 | 5, 14 | 4, 15 | "@mock-stream-context" 16 | ], 17 | "return": "@mock-stream" 18 | }, 19 | { 20 | "function": "get_resource_type", 21 | "params": [ 22 | "@mock-stream" 23 | ], 24 | "return": "stream" 25 | }, 26 | { 27 | "function": "stream_set_timeout", 28 | "params": [ 29 | "@mock-stream", 30 | 5 31 | ], 32 | "return": true 33 | }, 34 | { 35 | "function": "fwrite", 36 | "params": [ 37 | "@mock-stream" 38 | ], 39 | "return-op": "key-save", 40 | "return": 255 41 | }, 42 | { 43 | "function": "fgets", 44 | "params": [ 45 | "@mock-stream", 46 | 1024 47 | ], 48 | "return-op": "key-respond", 49 | "return": "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {key}\r\nX-Very-Long_Header: This is added to provoke split reads of headers in client 0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456\r\n" 50 | }, 51 | { 52 | "function": "fgets", 53 | "params": [ 54 | "@mock-stream", 55 | 1024 56 | ], 57 | "return-op": "key-respond", 58 | "return": "Next234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789\r\n\r\n" 59 | }, 60 | { 61 | "function": "fwrite", 62 | "params": [ 63 | "@mock-stream" 64 | ], 65 | "return": 13 66 | } 67 | ] -------------------------------------------------------------------------------- /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/scripts/receive-fragmentation.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "get_resource_type", 4 | "params": [ 5 | "@mock-stream" 6 | ], 7 | "return": "stream" 8 | }, 9 | { 10 | "function": "fread", 11 | "params": [ 12 | "@mock-stream", 13 | 2 14 | ], 15 | "return-op": "chr-array", 16 | "return": [1, 136] 17 | }, 18 | { 19 | "function": "fread", 20 | "params": [ 21 | "@mock-stream", 22 | 4 23 | ], 24 | "return-op": "chr-array", 25 | "return": [105, 29, 187, 18] 26 | }, 27 | { 28 | "function": "fread", 29 | "params": [ 30 | "@mock-stream", 31 | 8 32 | ], 33 | "return-op": "chr-array", 34 | "return": [36, 104, 215, 102, 0, 61, 221, 96] 35 | }, 36 | 37 | { 38 | "function": "fread", 39 | "params": [ 40 | "@mock-stream", 41 | 2 42 | ], 43 | "return-op": "chr-array", 44 | "return": [138, 139] 45 | }, 46 | { 47 | "function": "fread", 48 | "params": [ 49 | "@mock-stream", 50 | 4 51 | ], 52 | "return-op": "chr-array", 53 | "return": [1, 1, 1, 1] 54 | }, 55 | { 56 | "function": "fread", 57 | "params": [ 58 | "@mock-stream", 59 | 11 60 | ], 61 | "return-op": "chr-array", 62 | "return": [82, 100, 115, 119, 100, 115, 33, 113, 104, 111, 102] 63 | }, 64 | 65 | { 66 | "function": "get_resource_type", 67 | "params": [ 68 | "@mock-stream" 69 | ], 70 | "return": "stream" 71 | }, 72 | { 73 | "function": "fread", 74 | "params": [ 75 | "@mock-stream", 76 | 2 77 | ], 78 | "return-op": "chr-array", 79 | "return": [0, 136] 80 | }, 81 | { 82 | "function": "fread", 83 | "params": [ 84 | "@mock-stream", 85 | 4 86 | ], 87 | "return-op": "chr-array", 88 | "return": [221, 240, 46, 69] 89 | }, 90 | { 91 | "function": "fread", 92 | "params": [ 93 | "@mock-stream", 94 | 8 95 | ], 96 | "return-op": "chr-array", 97 | "return": [188, 151, 67, 32, 179, 132, 14, 49] 98 | }, 99 | { 100 | "function": "fread", 101 | "params": [ 102 | "@mock-stream", 103 | 2 104 | ], 105 | "return-op": "chr-array", 106 | "return": [128, 131] 107 | }, 108 | { 109 | "function": "fread", 110 | "params": [ 111 | "@mock-stream", 112 | 4 113 | ], 114 | "return-op": "chr-array", 115 | "return": [9, 60, 117, 193] 116 | }, 117 | { 118 | "function": "fread", 119 | "params": [ 120 | "@mock-stream", 121 | 3 122 | ], 123 | "return-op": "chr-array", 124 | "return": [108, 79, 1] 125 | } 126 | ] -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /tests/scripts/ping-pong.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "get_resource_type", 4 | "params": [ 5 | "@mock-stream" 6 | ], 7 | "return": "stream" 8 | }, 9 | { 10 | "function": "fwrite", 11 | "params": [ 12 | "@mock-stream" 13 | ], 14 | "return": 17 15 | }, 16 | { 17 | "function": "get_resource_type", 18 | "params": [ 19 | "@mock-stream" 20 | ], 21 | "return": "stream" 22 | }, 23 | { 24 | "function": "fwrite", 25 | "params": [ 26 | "@mock-stream" 27 | ], 28 | "return": 6 29 | }, 30 | { 31 | "function": "get_resource_type", 32 | "params": [ 33 | "@mock-stream" 34 | ], 35 | "return": "stream" 36 | }, 37 | { 38 | "function": "fread", 39 | "params": [ 40 | "@mock-stream", 41 | 2 42 | ], 43 | "return-op": "chr-array", 44 | "return": [138, 139] 45 | }, 46 | { 47 | "function": "fread", 48 | "params": [ 49 | "@mock-stream", 50 | 4 51 | ], 52 | "return-op": "chr-array", 53 | "return": [1, 1, 1, 1] 54 | }, 55 | { 56 | "function": "fread", 57 | "params": [ 58 | "@mock-stream", 59 | 11 60 | ], 61 | "return-op": "chr-array", 62 | "return": [82, 100, 115, 119, 100, 115, 33, 113, 104, 111, 102] 63 | }, 64 | { 65 | "function": "fread", 66 | "params": [ 67 | "@mock-stream", 68 | 2 69 | ], 70 | "return-op": "chr-array", 71 | "return": [138, 128] 72 | }, 73 | { 74 | "function": "fread", 75 | "params": [ 76 | "@mock-stream", 77 | 4 78 | ], 79 | "return-op": "chr-array", 80 | "return": [1, 1, 1, 1] 81 | }, 82 | { 83 | "function": "fread", 84 | "params": [ 85 | "@mock-stream", 86 | 2 87 | ], 88 | "return-op": "chr-array", 89 | "return": [137, 139] 90 | }, 91 | { 92 | "function": "fread", 93 | "params": [ 94 | "@mock-stream", 95 | 4 96 | ], 97 | "return-op": "chr-array", 98 | "return": [180, 77, 192, 201] 99 | }, 100 | { 101 | "function": "fread", 102 | "params": [ 103 | "@mock-stream", 104 | 11 105 | ], 106 | "return-op": "chr-array", 107 | "return": [247, 33, 169, 172, 218, 57, 224, 185, 221, 35, 167] 108 | }, 109 | { 110 | "function": "fwrite", 111 | "params": [ 112 | "@mock-stream" 113 | ], 114 | "return": 17 115 | }, 116 | { 117 | "function": "fread", 118 | "params": [ 119 | "@mock-stream", 120 | 2 121 | ], 122 | "return-op": "chr-array", 123 | "return": [129, 147] 124 | }, 125 | { 126 | "function": "fread", 127 | "params": [ 128 | "@mock-stream", 129 | 4 130 | ], 131 | "return-op": "chr-array", 132 | "return": [33, 111, 149, 174] 133 | }, 134 | { 135 | "function": "fread", 136 | "params": [ 137 | "@mock-stream", 138 | 19 139 | ], 140 | "return-op": "chr-array", 141 | "return": [115, 10, 246, 203, 72, 25, 252, 192, 70, 79, 244, 142, 76, 10, 230, 221, 64, 8, 240] 142 | } 143 | ] 144 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/scripts/server.accept-failed-http.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_socket_accept", 4 | "params": [ 5 | "@mock-socket" 6 | ], 7 | "return": "@mock-stream" 8 | }, 9 | { 10 | "function": "stream_socket_get_name", 11 | "params": [ 12 | "@mock-stream" 13 | ], 14 | "return": "127.0.0.1:12345" 15 | }, 16 | { 17 | "function": "stream_get_line", 18 | "params": [ 19 | "@mock-stream", 20 | 1024, 21 | "\r\n" 22 | ], 23 | "return": "missing http header" 24 | }, 25 | { 26 | "function": "stream_get_meta_data", 27 | "params": [ 28 | "@mock-stream" 29 | ], 30 | "return": { 31 | "timed_out": false, 32 | "blocked": true, 33 | "eof": false, 34 | "stream_type": "tcp_socket\/ssl", 35 | "mode": "r+", 36 | "unread_bytes": 171, 37 | "seekable": false 38 | } 39 | }, 40 | { 41 | "function": "feof", 42 | "params": [ 43 | "@mock-stream" 44 | ], 45 | "return": false 46 | }, 47 | { 48 | "function": "stream_get_line", 49 | "params": [ 50 | "@mock-stream", 51 | 1024, 52 | "\r\n" 53 | ], 54 | "return": "host: localhost:8000" 55 | }, 56 | { 57 | "function": "stream_get_meta_data", 58 | "params": [ 59 | "@mock-stream" 60 | ], 61 | "return": { 62 | "timed_out": false, 63 | "blocked": true, 64 | "eof": false, 65 | "stream_type": "tcp_socket\/ssl", 66 | "mode": "r+", 67 | "unread_bytes": 149, 68 | "seekable": false 69 | } 70 | }, 71 | { 72 | "function": "feof", 73 | "params": [ 74 | "@mock-stream" 75 | ], 76 | "return": false 77 | }, 78 | { 79 | "function": "stream_get_line", 80 | "params": [ 81 | "@mock-stream", 82 | 1024, 83 | "\r\n" 84 | ], 85 | "return": "user-agent: websocket-client-php" 86 | }, 87 | { 88 | "function": "stream_get_meta_data", 89 | "params": [ 90 | "@mock-stream" 91 | ], 92 | "return": { 93 | "timed_out": false, 94 | "blocked": true, 95 | "eof": false, 96 | "stream_type": "tcp_socket\/ssl", 97 | "mode": "r+", 98 | "unread_bytes": 115, 99 | "seekable": false 100 | } 101 | }, 102 | { 103 | "function": "feof", 104 | "params": [ 105 | "@mock-stream" 106 | ], 107 | "return": false 108 | }, 109 | { 110 | "function": "stream_get_line", 111 | "params": [ 112 | "@mock-stream", 113 | 1024, 114 | "\r\n" 115 | ], 116 | "return": "connection: Upgrade" 117 | }, 118 | { 119 | "function": "stream_get_meta_data", 120 | "params": [ 121 | "@mock-stream" 122 | ], 123 | "return": { 124 | "timed_out": false, 125 | "blocked": true, 126 | "eof": false, 127 | "stream_type": "tcp_socket\/ssl", 128 | "mode": "r+", 129 | "unread_bytes": 94, 130 | "seekable": false 131 | } 132 | }, 133 | { 134 | "function": "feof", 135 | "params": [ 136 | "@mock-stream" 137 | ], 138 | "return": false 139 | }, 140 | { 141 | "function": "stream_get_line", 142 | "params": [ 143 | "@mock-stream", 144 | 1024, 145 | "\r\n" 146 | ], 147 | "return": "upgrade: websocket" 148 | }, 149 | { 150 | "function": "stream_get_meta_data", 151 | "params": [ 152 | "@mock-stream" 153 | ], 154 | "return": { 155 | "timed_out": false, 156 | "blocked": true, 157 | "eof": false, 158 | "stream_type": "tcp_socket\/ssl", 159 | "mode": "r+", 160 | "unread_bytes": 74, 161 | "seekable": false 162 | } 163 | }, 164 | { 165 | "function": "feof", 166 | "params": [ 167 | "@mock-stream" 168 | ], 169 | "return": false 170 | }, 171 | { 172 | "function": "stream_get_line", 173 | "params": [ 174 | "@mock-stream", 175 | 1024, 176 | "\r\n" 177 | ], 178 | "return": "no key in upgrade request" 179 | }, 180 | { 181 | "function": "stream_get_meta_data", 182 | "params": [ 183 | "@mock-stream" 184 | ], 185 | "return": { 186 | "timed_out": false, 187 | "blocked": true, 188 | "eof": false, 189 | "stream_type": "tcp_socket\/ssl", 190 | "mode": "r+", 191 | "unread_bytes": 29, 192 | "seekable": false 193 | } 194 | }, 195 | { 196 | "function": "feof", 197 | "params": [ 198 | "@mock-stream" 199 | ], 200 | "return": false 201 | }, 202 | { 203 | "function": "stream_get_line", 204 | "params": [ 205 | "@mock-stream", 206 | 1024, 207 | "\r\n" 208 | ], 209 | "return": "sec-websocket-version: 13" 210 | }, 211 | { 212 | "function": "stream_get_meta_data", 213 | "params": [ 214 | "@mock-stream" 215 | ], 216 | "return": { 217 | "timed_out": false, 218 | "blocked": true, 219 | "eof": false, 220 | "stream_type": "tcp_socket\/ssl", 221 | "mode": "r+", 222 | "unread_bytes": 2, 223 | "seekable": false 224 | } 225 | }, 226 | { 227 | "function": "feof", 228 | "params": [ 229 | "@mock-stream" 230 | ], 231 | "return": false 232 | } 233 | , 234 | { 235 | "function": "stream_get_line", 236 | "params": [ 237 | "@mock-stream", 238 | 1024, 239 | "\r\n" 240 | ], 241 | "return": "" 242 | }, 243 | { 244 | "function": "stream_get_meta_data", 245 | "params": [ 246 | "@mock-stream" 247 | ], 248 | "return": { 249 | "timed_out": false, 250 | "blocked": true, 251 | "eof": false, 252 | "stream_type": "tcp_socket\/ssl", 253 | "mode": "r+", 254 | "unread_bytes": 0, 255 | "seekable": false 256 | } 257 | }, 258 | { 259 | "function": "feof", 260 | "params": [ 261 | "@mock-stream" 262 | ], 263 | "return": false 264 | } 265 | ] -------------------------------------------------------------------------------- /tests/scripts/server.accept-failed-ws-key.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_socket_accept", 4 | "params": [ 5 | "@mock-socket" 6 | ], 7 | "return": "@mock-stream" 8 | }, 9 | { 10 | "function": "stream_socket_get_name", 11 | "params": [ 12 | "@mock-stream" 13 | ], 14 | "return": "127.0.0.1:12345" 15 | }, 16 | { 17 | "function": "stream_get_line", 18 | "params": [ 19 | "@mock-stream", 20 | 1024, 21 | "\r\n" 22 | ], 23 | "return": "GET \/my\/mock\/path HTTP\/1.1" 24 | }, 25 | { 26 | "function": "stream_get_meta_data", 27 | "params": [ 28 | "@mock-stream" 29 | ], 30 | "return": { 31 | "timed_out": false, 32 | "blocked": true, 33 | "eof": false, 34 | "stream_type": "tcp_socket\/ssl", 35 | "mode": "r+", 36 | "unread_bytes": 171, 37 | "seekable": false 38 | } 39 | }, 40 | { 41 | "function": "feof", 42 | "params": [ 43 | "@mock-stream" 44 | ], 45 | "return": false 46 | }, 47 | { 48 | "function": "stream_get_line", 49 | "params": [ 50 | "@mock-stream", 51 | 1024, 52 | "\r\n" 53 | ], 54 | "return": "host: localhost:8000" 55 | }, 56 | { 57 | "function": "stream_get_meta_data", 58 | "params": [ 59 | "@mock-stream" 60 | ], 61 | "return": { 62 | "timed_out": false, 63 | "blocked": true, 64 | "eof": false, 65 | "stream_type": "tcp_socket\/ssl", 66 | "mode": "r+", 67 | "unread_bytes": 149, 68 | "seekable": false 69 | } 70 | }, 71 | { 72 | "function": "feof", 73 | "params": [ 74 | "@mock-stream" 75 | ], 76 | "return": false 77 | }, 78 | { 79 | "function": "stream_get_line", 80 | "params": [ 81 | "@mock-stream", 82 | 1024, 83 | "\r\n" 84 | ], 85 | "return": "user-agent: websocket-client-php" 86 | }, 87 | { 88 | "function": "stream_get_meta_data", 89 | "params": [ 90 | "@mock-stream" 91 | ], 92 | "return": { 93 | "timed_out": false, 94 | "blocked": true, 95 | "eof": false, 96 | "stream_type": "tcp_socket\/ssl", 97 | "mode": "r+", 98 | "unread_bytes": 115, 99 | "seekable": false 100 | } 101 | }, 102 | { 103 | "function": "feof", 104 | "params": [ 105 | "@mock-stream" 106 | ], 107 | "return": false 108 | }, 109 | { 110 | "function": "stream_get_line", 111 | "params": [ 112 | "@mock-stream", 113 | 1024, 114 | "\r\n" 115 | ], 116 | "return": "connection: Upgrade" 117 | }, 118 | { 119 | "function": "stream_get_meta_data", 120 | "params": [ 121 | "@mock-stream" 122 | ], 123 | "return": { 124 | "timed_out": false, 125 | "blocked": true, 126 | "eof": false, 127 | "stream_type": "tcp_socket\/ssl", 128 | "mode": "r+", 129 | "unread_bytes": 94, 130 | "seekable": false 131 | } 132 | }, 133 | { 134 | "function": "feof", 135 | "params": [ 136 | "@mock-stream" 137 | ], 138 | "return": false 139 | }, 140 | { 141 | "function": "stream_get_line", 142 | "params": [ 143 | "@mock-stream", 144 | 1024, 145 | "\r\n" 146 | ], 147 | "return": "upgrade: websocket" 148 | }, 149 | { 150 | "function": "stream_get_meta_data", 151 | "params": [ 152 | "@mock-stream" 153 | ], 154 | "return": { 155 | "timed_out": false, 156 | "blocked": true, 157 | "eof": false, 158 | "stream_type": "tcp_socket\/ssl", 159 | "mode": "r+", 160 | "unread_bytes": 74, 161 | "seekable": false 162 | } 163 | }, 164 | { 165 | "function": "feof", 166 | "params": [ 167 | "@mock-stream" 168 | ], 169 | "return": false 170 | }, 171 | { 172 | "function": "stream_get_line", 173 | "params": [ 174 | "@mock-stream", 175 | 1024, 176 | "\r\n" 177 | ], 178 | "return": "no key in upgrade request" 179 | }, 180 | { 181 | "function": "stream_get_meta_data", 182 | "params": [ 183 | "@mock-stream" 184 | ], 185 | "return": { 186 | "timed_out": false, 187 | "blocked": true, 188 | "eof": false, 189 | "stream_type": "tcp_socket\/ssl", 190 | "mode": "r+", 191 | "unread_bytes": 29, 192 | "seekable": false 193 | } 194 | }, 195 | { 196 | "function": "feof", 197 | "params": [ 198 | "@mock-stream" 199 | ], 200 | "return": false 201 | }, 202 | { 203 | "function": "stream_get_line", 204 | "params": [ 205 | "@mock-stream", 206 | 1024, 207 | "\r\n" 208 | ], 209 | "return": "sec-websocket-version: 13" 210 | }, 211 | { 212 | "function": "stream_get_meta_data", 213 | "params": [ 214 | "@mock-stream" 215 | ], 216 | "return": { 217 | "timed_out": false, 218 | "blocked": true, 219 | "eof": false, 220 | "stream_type": "tcp_socket\/ssl", 221 | "mode": "r+", 222 | "unread_bytes": 2, 223 | "seekable": false 224 | } 225 | }, 226 | { 227 | "function": "feof", 228 | "params": [ 229 | "@mock-stream" 230 | ], 231 | "return": false 232 | } 233 | , 234 | { 235 | "function": "stream_get_line", 236 | "params": [ 237 | "@mock-stream", 238 | 1024, 239 | "\r\n" 240 | ], 241 | "return": "" 242 | }, 243 | { 244 | "function": "stream_get_meta_data", 245 | "params": [ 246 | "@mock-stream" 247 | ], 248 | "return": { 249 | "timed_out": false, 250 | "blocked": true, 251 | "eof": false, 252 | "stream_type": "tcp_socket\/ssl", 253 | "mode": "r+", 254 | "unread_bytes": 0, 255 | "seekable": false 256 | } 257 | }, 258 | { 259 | "function": "feof", 260 | "params": [ 261 | "@mock-stream" 262 | ], 263 | "return": false 264 | } 265 | ] -------------------------------------------------------------------------------- /tests/scripts/server.accept.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_socket_accept", 4 | "params": [ 5 | "@mock-socket" 6 | ], 7 | "return": "@mock-stream" 8 | }, 9 | { 10 | "function": "stream_socket_get_name", 11 | "params": [ 12 | "@mock-stream" 13 | ], 14 | "return": "127.0.0.1:12345" 15 | }, 16 | { 17 | "function": "stream_get_line", 18 | "params": [ 19 | "@mock-stream", 20 | 1024, 21 | "\r\n" 22 | ], 23 | "return": "GET \/my\/mock\/path HTTP\/1.1" 24 | }, 25 | { 26 | "function": "stream_get_meta_data", 27 | "params": [ 28 | "@mock-stream" 29 | ], 30 | "return": { 31 | "timed_out": false, 32 | "blocked": true, 33 | "eof": false, 34 | "stream_type": "tcp_socket\/ssl", 35 | "mode": "r+", 36 | "unread_bytes": 171, 37 | "seekable": false 38 | } 39 | }, 40 | { 41 | "function": "feof", 42 | "params": [ 43 | "@mock-stream" 44 | ], 45 | "return": false 46 | }, 47 | { 48 | "function": "stream_get_line", 49 | "params": [ 50 | "@mock-stream", 51 | 1024, 52 | "\r\n" 53 | ], 54 | "return": "host: localhost:8000" 55 | }, 56 | { 57 | "function": "stream_get_meta_data", 58 | "params": [ 59 | "@mock-stream" 60 | ], 61 | "return": { 62 | "timed_out": false, 63 | "blocked": true, 64 | "eof": false, 65 | "stream_type": "tcp_socket\/ssl", 66 | "mode": "r+", 67 | "unread_bytes": 149, 68 | "seekable": false 69 | } 70 | }, 71 | { 72 | "function": "feof", 73 | "params": [ 74 | "@mock-stream" 75 | ], 76 | "return": false 77 | }, 78 | { 79 | "function": "stream_get_line", 80 | "params": [ 81 | "@mock-stream", 82 | 1024, 83 | "\r\n" 84 | ], 85 | "return": "user-agent: websocket-client-php" 86 | }, 87 | { 88 | "function": "stream_get_meta_data", 89 | "params": [ 90 | "@mock-stream" 91 | ], 92 | "return": { 93 | "timed_out": false, 94 | "blocked": true, 95 | "eof": false, 96 | "stream_type": "tcp_socket\/ssl", 97 | "mode": "r+", 98 | "unread_bytes": 115, 99 | "seekable": false 100 | } 101 | }, 102 | { 103 | "function": "feof", 104 | "params": [ 105 | "@mock-stream" 106 | ], 107 | "return": false 108 | }, 109 | { 110 | "function": "stream_get_line", 111 | "params": [ 112 | "@mock-stream", 113 | 1024, 114 | "\r\n" 115 | ], 116 | "return": "connection: Upgrade" 117 | }, 118 | { 119 | "function": "stream_get_meta_data", 120 | "params": [ 121 | "@mock-stream" 122 | ], 123 | "return": { 124 | "timed_out": false, 125 | "blocked": true, 126 | "eof": false, 127 | "stream_type": "tcp_socket\/ssl", 128 | "mode": "r+", 129 | "unread_bytes": 94, 130 | "seekable": false 131 | } 132 | }, 133 | { 134 | "function": "feof", 135 | "params": [ 136 | "@mock-stream" 137 | ], 138 | "return": false 139 | }, 140 | { 141 | "function": "stream_get_line", 142 | "params": [ 143 | "@mock-stream", 144 | 1024, 145 | "\r\n" 146 | ], 147 | "return": "upgrade: websocket" 148 | }, 149 | { 150 | "function": "stream_get_meta_data", 151 | "params": [ 152 | "@mock-stream" 153 | ], 154 | "return": { 155 | "timed_out": false, 156 | "blocked": true, 157 | "eof": false, 158 | "stream_type": "tcp_socket\/ssl", 159 | "mode": "r+", 160 | "unread_bytes": 74, 161 | "seekable": false 162 | } 163 | }, 164 | { 165 | "function": "feof", 166 | "params": [ 167 | "@mock-stream" 168 | ], 169 | "return": false 170 | }, 171 | { 172 | "function": "stream_get_line", 173 | "params": [ 174 | "@mock-stream", 175 | 1024, 176 | "\r\n" 177 | ], 178 | "return": "sec-websocket-key: cktLWXhUdDQ2OXF0ZCFqOQ==" 179 | }, 180 | { 181 | "function": "stream_get_meta_data", 182 | "params": [ 183 | "@mock-stream" 184 | ], 185 | "return": { 186 | "timed_out": false, 187 | "blocked": true, 188 | "eof": false, 189 | "stream_type": "tcp_socket\/ssl", 190 | "mode": "r+", 191 | "unread_bytes": 29, 192 | "seekable": false 193 | } 194 | }, 195 | { 196 | "function": "feof", 197 | "params": [ 198 | "@mock-stream" 199 | ], 200 | "return": false 201 | }, 202 | { 203 | "function": "stream_get_line", 204 | "params": [ 205 | "@mock-stream", 206 | 1024, 207 | "\r\n" 208 | ], 209 | "return": "sec-websocket-version: 13" 210 | }, 211 | { 212 | "function": "stream_get_meta_data", 213 | "params": [ 214 | "@mock-stream" 215 | ], 216 | "return": { 217 | "timed_out": false, 218 | "blocked": true, 219 | "eof": false, 220 | "stream_type": "tcp_socket\/ssl", 221 | "mode": "r+", 222 | "unread_bytes": 2, 223 | "seekable": false 224 | } 225 | }, 226 | { 227 | "function": "feof", 228 | "params": [ 229 | "@mock-stream" 230 | ], 231 | "return": false 232 | } 233 | , 234 | { 235 | "function": "stream_get_line", 236 | "params": [ 237 | "@mock-stream", 238 | 1024, 239 | "\r\n" 240 | ], 241 | "return": "" 242 | }, 243 | { 244 | "function": "stream_get_meta_data", 245 | "params": [ 246 | "@mock-stream" 247 | ], 248 | "return": { 249 | "timed_out": false, 250 | "blocked": true, 251 | "eof": false, 252 | "stream_type": "tcp_socket\/ssl", 253 | "mode": "r+", 254 | "unread_bytes": 0, 255 | "seekable": false 256 | } 257 | }, 258 | { 259 | "function": "feof", 260 | "params": [ 261 | "@mock-stream" 262 | ], 263 | "return": false 264 | }, 265 | { 266 | "function": "fwrite", 267 | "params": [ 268 | "@mock-stream", 269 | "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: YmysboNHNoWzWVeQpduY7xELjgU=\r\n\r\n" 270 | ], 271 | "return": 129 272 | }, 273 | { 274 | "function": "fwrite", 275 | "params": [ 276 | "@mock-stream" 277 | ], 278 | "return": 13 279 | }, 280 | { 281 | "function": "get_resource_type", 282 | "params": [ 283 | "@mock-stream" 284 | ], 285 | "return": "stream" 286 | } 287 | ] -------------------------------------------------------------------------------- /tests/scripts/server.accept-timeout.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_socket_accept", 4 | "params": [ 5 | "@mock-socket", 6 | 300 7 | ], 8 | "return": "@mock-stream" 9 | }, 10 | { 11 | "function": "stream_set_timeout", 12 | "params": [ 13 | "@mock-stream", 14 | 300 15 | ], 16 | "return": true 17 | }, 18 | { 19 | "function": "stream_socket_get_name", 20 | "params": [ 21 | "@mock-stream" 22 | ], 23 | "return": "127.0.0.1:12345" 24 | }, 25 | { 26 | "function": "stream_get_line", 27 | "params": [ 28 | "@mock-stream", 29 | 1024, 30 | "\r\n" 31 | ], 32 | "return": "GET \/my\/mock\/path HTTP\/1.1" 33 | }, 34 | { 35 | "function": "stream_get_meta_data", 36 | "params": [ 37 | "@mock-stream" 38 | ], 39 | "return": { 40 | "timed_out": false, 41 | "blocked": true, 42 | "eof": false, 43 | "stream_type": "tcp_socket\/ssl", 44 | "mode": "r+", 45 | "unread_bytes": 171, 46 | "seekable": false 47 | } 48 | }, 49 | { 50 | "function": "feof", 51 | "params": [ 52 | "@mock-stream" 53 | ], 54 | "return": false 55 | }, 56 | { 57 | "function": "stream_get_line", 58 | "params": [ 59 | "@mock-stream", 60 | 1024, 61 | "\r\n" 62 | ], 63 | "return": "host: localhost:8000" 64 | }, 65 | { 66 | "function": "stream_get_meta_data", 67 | "params": [ 68 | "@mock-stream" 69 | ], 70 | "return": { 71 | "timed_out": false, 72 | "blocked": true, 73 | "eof": false, 74 | "stream_type": "tcp_socket\/ssl", 75 | "mode": "r+", 76 | "unread_bytes": 149, 77 | "seekable": false 78 | } 79 | }, 80 | { 81 | "function": "feof", 82 | "params": [ 83 | "@mock-stream" 84 | ], 85 | "return": false 86 | }, 87 | { 88 | "function": "stream_get_line", 89 | "params": [ 90 | "@mock-stream", 91 | 1024, 92 | "\r\n" 93 | ], 94 | "return": "user-agent: websocket-client-php" 95 | }, 96 | { 97 | "function": "stream_get_meta_data", 98 | "params": [ 99 | "@mock-stream" 100 | ], 101 | "return": { 102 | "timed_out": false, 103 | "blocked": true, 104 | "eof": false, 105 | "stream_type": "tcp_socket\/ssl", 106 | "mode": "r+", 107 | "unread_bytes": 115, 108 | "seekable": false 109 | } 110 | }, 111 | { 112 | "function": "feof", 113 | "params": [ 114 | "@mock-stream" 115 | ], 116 | "return": false 117 | }, 118 | { 119 | "function": "stream_get_line", 120 | "params": [ 121 | "@mock-stream", 122 | 1024, 123 | "\r\n" 124 | ], 125 | "return": "connection: Upgrade" 126 | }, 127 | { 128 | "function": "stream_get_meta_data", 129 | "params": [ 130 | "@mock-stream" 131 | ], 132 | "return": { 133 | "timed_out": false, 134 | "blocked": true, 135 | "eof": false, 136 | "stream_type": "tcp_socket\/ssl", 137 | "mode": "r+", 138 | "unread_bytes": 94, 139 | "seekable": false 140 | } 141 | }, 142 | { 143 | "function": "feof", 144 | "params": [ 145 | "@mock-stream" 146 | ], 147 | "return": false 148 | }, 149 | { 150 | "function": "stream_get_line", 151 | "params": [ 152 | "@mock-stream", 153 | 1024, 154 | "\r\n" 155 | ], 156 | "return": "upgrade: websocket" 157 | }, 158 | { 159 | "function": "stream_get_meta_data", 160 | "params": [ 161 | "@mock-stream" 162 | ], 163 | "return": { 164 | "timed_out": false, 165 | "blocked": true, 166 | "eof": false, 167 | "stream_type": "tcp_socket\/ssl", 168 | "mode": "r+", 169 | "unread_bytes": 74, 170 | "seekable": false 171 | } 172 | }, 173 | { 174 | "function": "feof", 175 | "params": [ 176 | "@mock-stream" 177 | ], 178 | "return": false 179 | }, 180 | { 181 | "function": "stream_get_line", 182 | "params": [ 183 | "@mock-stream", 184 | 1024, 185 | "\r\n" 186 | ], 187 | "return": "sec-websocket-key: cktLWXhUdDQ2OXF0ZCFqOQ==" 188 | }, 189 | { 190 | "function": "stream_get_meta_data", 191 | "params": [ 192 | "@mock-stream" 193 | ], 194 | "return": { 195 | "timed_out": false, 196 | "blocked": true, 197 | "eof": false, 198 | "stream_type": "tcp_socket\/ssl", 199 | "mode": "r+", 200 | "unread_bytes": 29, 201 | "seekable": false 202 | } 203 | }, 204 | { 205 | "function": "feof", 206 | "params": [ 207 | "@mock-stream" 208 | ], 209 | "return": false 210 | }, 211 | { 212 | "function": "stream_get_line", 213 | "params": [ 214 | "@mock-stream", 215 | 1024, 216 | "\r\n" 217 | ], 218 | "return": "sec-websocket-version: 13" 219 | }, 220 | { 221 | "function": "stream_get_meta_data", 222 | "params": [ 223 | "@mock-stream" 224 | ], 225 | "return": { 226 | "timed_out": false, 227 | "blocked": true, 228 | "eof": false, 229 | "stream_type": "tcp_socket\/ssl", 230 | "mode": "r+", 231 | "unread_bytes": 2, 232 | "seekable": false 233 | } 234 | }, 235 | { 236 | "function": "feof", 237 | "params": [ 238 | "@mock-stream" 239 | ], 240 | "return": false 241 | } 242 | , 243 | { 244 | "function": "stream_get_line", 245 | "params": [ 246 | "@mock-stream", 247 | 1024, 248 | "\r\n" 249 | ], 250 | "return": "" 251 | }, 252 | { 253 | "function": "stream_get_meta_data", 254 | "params": [ 255 | "@mock-stream" 256 | ], 257 | "return": { 258 | "timed_out": false, 259 | "blocked": true, 260 | "eof": false, 261 | "stream_type": "tcp_socket\/ssl", 262 | "mode": "r+", 263 | "unread_bytes": 0, 264 | "seekable": false 265 | } 266 | }, 267 | { 268 | "function": "feof", 269 | "params": [ 270 | "@mock-stream" 271 | ], 272 | "return": false 273 | }, 274 | { 275 | "function": "fwrite", 276 | "params": [ 277 | "@mock-stream", 278 | "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: YmysboNHNoWzWVeQpduY7xELjgU=\r\n\r\n" 279 | ], 280 | "return": 129 281 | }, 282 | { 283 | "function": "fwrite", 284 | "params": [ 285 | "@mock-stream" 286 | ], 287 | "return": 13 288 | } 289 | ] -------------------------------------------------------------------------------- /tests/scripts/server.accept-destruct.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "function": "stream_socket_accept", 4 | "params": [ 5 | "@mock-socket" 6 | ], 7 | "return": "@mock-stream" 8 | }, 9 | { 10 | "function": "stream_socket_get_name", 11 | "params": [ 12 | "@mock-stream" 13 | ], 14 | "return": "127.0.0.1:12345" 15 | }, 16 | { 17 | "function": "stream_get_line", 18 | "params": [ 19 | "@mock-stream", 20 | 1024, 21 | "\r\n" 22 | ], 23 | "return": "GET \/my\/mock\/path HTTP\/1.1" 24 | }, 25 | { 26 | "function": "stream_get_meta_data", 27 | "params": [ 28 | "@mock-stream" 29 | ], 30 | "return": { 31 | "timed_out": false, 32 | "blocked": true, 33 | "eof": false, 34 | "stream_type": "tcp_socket\/ssl", 35 | "mode": "r+", 36 | "unread_bytes": 171, 37 | "seekable": false 38 | } 39 | }, 40 | { 41 | "function": "feof", 42 | "params": [ 43 | "@mock-stream" 44 | ], 45 | "return": false 46 | }, 47 | { 48 | "function": "stream_get_line", 49 | "params": [ 50 | "@mock-stream", 51 | 1024, 52 | "\r\n" 53 | ], 54 | "return": "host: localhost:8000" 55 | }, 56 | { 57 | "function": "stream_get_meta_data", 58 | "params": [ 59 | "@mock-stream" 60 | ], 61 | "return": { 62 | "timed_out": false, 63 | "blocked": true, 64 | "eof": false, 65 | "stream_type": "tcp_socket\/ssl", 66 | "mode": "r+", 67 | "unread_bytes": 149, 68 | "seekable": false 69 | } 70 | }, 71 | { 72 | "function": "feof", 73 | "params": [ 74 | "@mock-stream" 75 | ], 76 | "return": false 77 | }, 78 | { 79 | "function": "stream_get_line", 80 | "params": [ 81 | "@mock-stream", 82 | 1024, 83 | "\r\n" 84 | ], 85 | "return": "user-agent: websocket-client-php" 86 | }, 87 | { 88 | "function": "stream_get_meta_data", 89 | "params": [ 90 | "@mock-stream" 91 | ], 92 | "return": { 93 | "timed_out": false, 94 | "blocked": true, 95 | "eof": false, 96 | "stream_type": "tcp_socket\/ssl", 97 | "mode": "r+", 98 | "unread_bytes": 115, 99 | "seekable": false 100 | } 101 | }, 102 | { 103 | "function": "feof", 104 | "params": [ 105 | "@mock-stream" 106 | ], 107 | "return": false 108 | }, 109 | { 110 | "function": "stream_get_line", 111 | "params": [ 112 | "@mock-stream", 113 | 1024, 114 | "\r\n" 115 | ], 116 | "return": "connection: Upgrade" 117 | }, 118 | { 119 | "function": "stream_get_meta_data", 120 | "params": [ 121 | "@mock-stream" 122 | ], 123 | "return": { 124 | "timed_out": false, 125 | "blocked": true, 126 | "eof": false, 127 | "stream_type": "tcp_socket\/ssl", 128 | "mode": "r+", 129 | "unread_bytes": 94, 130 | "seekable": false 131 | } 132 | }, 133 | { 134 | "function": "feof", 135 | "params": [ 136 | "@mock-stream" 137 | ], 138 | "return": false 139 | }, 140 | { 141 | "function": "stream_get_line", 142 | "params": [ 143 | "@mock-stream", 144 | 1024, 145 | "\r\n" 146 | ], 147 | "return": "upgrade: websocket" 148 | }, 149 | { 150 | "function": "stream_get_meta_data", 151 | "params": [ 152 | "@mock-stream" 153 | ], 154 | "return": { 155 | "timed_out": false, 156 | "blocked": true, 157 | "eof": false, 158 | "stream_type": "tcp_socket\/ssl", 159 | "mode": "r+", 160 | "unread_bytes": 74, 161 | "seekable": false 162 | } 163 | }, 164 | { 165 | "function": "feof", 166 | "params": [ 167 | "@mock-stream" 168 | ], 169 | "return": false 170 | }, 171 | { 172 | "function": "stream_get_line", 173 | "params": [ 174 | "@mock-stream", 175 | 1024, 176 | "\r\n" 177 | ], 178 | "return": "sec-websocket-key: cktLWXhUdDQ2OXF0ZCFqOQ==" 179 | }, 180 | { 181 | "function": "stream_get_meta_data", 182 | "params": [ 183 | "@mock-stream" 184 | ], 185 | "return": { 186 | "timed_out": false, 187 | "blocked": true, 188 | "eof": false, 189 | "stream_type": "tcp_socket\/ssl", 190 | "mode": "r+", 191 | "unread_bytes": 29, 192 | "seekable": false 193 | } 194 | }, 195 | { 196 | "function": "feof", 197 | "params": [ 198 | "@mock-stream" 199 | ], 200 | "return": false 201 | }, 202 | { 203 | "function": "stream_get_line", 204 | "params": [ 205 | "@mock-stream", 206 | 1024, 207 | "\r\n" 208 | ], 209 | "return": "sec-websocket-version: 13" 210 | }, 211 | { 212 | "function": "stream_get_meta_data", 213 | "params": [ 214 | "@mock-stream" 215 | ], 216 | "return": { 217 | "timed_out": false, 218 | "blocked": true, 219 | "eof": false, 220 | "stream_type": "tcp_socket\/ssl", 221 | "mode": "r+", 222 | "unread_bytes": 2, 223 | "seekable": false 224 | } 225 | }, 226 | { 227 | "function": "feof", 228 | "params": [ 229 | "@mock-stream" 230 | ], 231 | "return": false 232 | } 233 | , 234 | { 235 | "function": "stream_get_line", 236 | "params": [ 237 | "@mock-stream", 238 | 1024, 239 | "\r\n" 240 | ], 241 | "return": "" 242 | }, 243 | { 244 | "function": "stream_get_meta_data", 245 | "params": [ 246 | "@mock-stream" 247 | ], 248 | "return": { 249 | "timed_out": false, 250 | "blocked": true, 251 | "eof": false, 252 | "stream_type": "tcp_socket\/ssl", 253 | "mode": "r+", 254 | "unread_bytes": 0, 255 | "seekable": false 256 | } 257 | }, 258 | { 259 | "function": "feof", 260 | "params": [ 261 | "@mock-stream" 262 | ], 263 | "return": false 264 | }, 265 | { 266 | "function": "fwrite", 267 | "params": [ 268 | "@mock-stream", 269 | "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: YmysboNHNoWzWVeQpduY7xELjgU=\r\n\r\n" 270 | ], 271 | "return": 129 272 | }, 273 | { 274 | "function": "fread", 275 | "params": [ 276 | "@mock-stream", 277 | 2 278 | ], 279 | "return-op": "chr-array", 280 | "return": [129, 147] 281 | }, 282 | { 283 | "function": "fread", 284 | "params": [ 285 | "@mock-stream", 286 | 4 287 | ], 288 | "return-op": "chr-array", 289 | "return": [33, 111, 149, 174] 290 | }, 291 | { 292 | "function": "fread", 293 | "params": [ 294 | "@mock-stream", 295 | 19 296 | ], 297 | "return-op": "chr-array", 298 | "return": [115, 10, 246, 203, 72, 25, 252, 192, 70, 79, 244, 142, 76, 10, 230, 221, 64, 8, 240] 299 | }, 300 | 301 | { 302 | "function": "get_resource_type", 303 | "params": [ 304 | "@mock-stream" 305 | ], 306 | "return": "stream" 307 | }, 308 | { 309 | "function": "fclose", 310 | "params": [ 311 | "@mock-stream" 312 | ], 313 | "return": true 314 | } 315 | ] -------------------------------------------------------------------------------- /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/Client.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 | --------------------------------------------------------------------------------