diff --git a/.travis.yml b/.travis.yml index 1161f57..0cde3bc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,6 @@ php: - 7.4 - 7.3 - 7.2 - - 7.1 before_script: - make install build diff --git a/README.md b/README.md index 3863641..18311ba 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ It does not include convenience operations such as listeners and implicit error - [Client](docs/Client.md) - [Server](docs/Server.md) +- [Message](docs/Message.md) - [Examples](docs/Examples.md) - [Changelog](docs/Changelog.md) - [Contributing](docs/Contributing.md) @@ -23,7 +24,8 @@ Preferred way to install is with [Composer](https://getcomposer.org/). composer require textalk/websocket ``` -* Current version support PHP versions `^7.1`. +* Current version support PHP versions `^7.2|8.0`. +* For PHP `7.1` support use version `1.4`. * For PHP `^5.4` and `7.0` support use version `1.3`. ## Client @@ -33,7 +35,7 @@ It internally supports Upgrade handshake and implicit close and ping/pong operat ```php $client = new WebSocket\Client("ws://echo.websocket.org/"); -$client->send("Hello WebSocket.org!"); +$client->text("Hello WebSocket.org!"); echo $client->receive(); $client->close(); ``` @@ -50,7 +52,7 @@ If you require this kind of server behavior, you need to build it on top of prov $server = new WebSocket\Server(); $server->accept(); $message = $server->receive(); -$server->send($message); +$server->text($message); $server->close(); ``` @@ -61,4 +63,4 @@ $server->close(); Fredrik Liljegren, Armen Baghumian Sankbarani, Ruslan Bekenev, Joshua Thijssen, Simon Lipp, Quentin Bellus, Patrick McCarren, swmcdonnell, Ignas Bernotas, Mark Herhold, Andreas Palm, Sören Jensen, pmaasz, Alexey Stavrov, -Michael Slezak, Pierre Seznec, rmeisler, Nickolay V. Shmyrev. +Michael Slezak, Pierre Seznec, rmeisler, Nickolay V. Shmyrev, Christoph Kempen. diff --git a/composer.json b/composer.json index 28b2251..7ab7045 100644 --- a/composer.json +++ b/composer.json @@ -23,11 +23,11 @@ } }, "require": { - "php": "^7.1 | ^8.0", + "php": "^7.2 | ^8.0", "psr/log": "^1.0" }, "require-dev": { - "phpunit/phpunit": "^7.0|^8.0|^9.0", + "phpunit/phpunit": "^8.0|^9.0", "php-coveralls/php-coveralls": "^2.0", "squizlabs/php_codesniffer": "^3.5" } diff --git a/docs/Changelog.md b/docs/Changelog.md index f68dfef..63f932d 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -1,7 +1,22 @@ -[Client](Client.md) • [Server](Server.md) • [Examples](Examples.md) • Changelog • [Contributing](Contributing.md) +[Client](Client.md) • [Server](Server.md) • [Message](Message.md) • [Examples](Examples.md) • Changelog • [Contributing](Contributing.md) # Websocket: Changelog +## `v1.5` + + > PHP version `^7.2` + +### `1.5.0` + + * Convenience send methods; text(), binary(), ping(), pong() (@sirn-se) + * Optional Message instance as receive() method return (@sirn-se) + * Opcode filter for receive() method (@sirn-se) + * Added PHP `8.0` support (@webpatser) + * Dropped PHP `7.1` support (@sirn-se) + * Fix for unordered fragmented messages (@sirn-se) + * Improved error handling on stream calls (@sirn-se) + * Various code re-write (@sirn-se) + ## `v1.4` > PHP version `^7.1` diff --git a/docs/Client.md b/docs/Client.md index 37076e7..9124bf8 100644 --- a/docs/Client.md +++ b/docs/Client.md @@ -1,4 +1,4 @@ -Client • [Server](Server.md) • [Examples](Examples.md) • [Changelog](Changelog.md) • [Contributing](Contributing.md) +Client • [Server](Server.md) • [Message](Message.md) • [Examples](Examples.md) • [Changelog](Changelog.md) • [Contributing](Contributing.md) # Websocket: Client @@ -12,11 +12,18 @@ WebSocket\Client { public __construct(string $uri, array $options = []) public __destruct() + public __toString() : string + public text(string $payload) : void + public binary(string $payload) : void + public ping(string $payload = '') : void + public pong(string $payload = '') : void public send(mixed $payload, string $opcode = 'text', bool $masked = true) : void public receive() : mixed public close(int $status = 1000, mixed $message = 'ttfn') : mixed + public getName() : string|null + public getPier() : string|null public getLastOpcode() : string public getCloseStatus() : int public isConnected() : bool @@ -35,7 +42,7 @@ This example send a single message to a server, and output the response. ```php $client = new WebSocket\Client("ws://echo.websocket.org/"); -$client->send("Hello WebSocket.org!"); +$client->text("Hello WebSocket.org!"); echo $client->receive(); $client->close(); ``` @@ -60,16 +67,49 @@ while (true) { $client->close(); ``` +### Filtering received messages + +By default the `receive()` method return messages of 'text' and 'binary' opcode. +The filter option allows you to specify which message types to return. + +```php +$client = new WebSocket\Client("ws://echo.websocket.org/", ['filter' => ['text']]); +$client->receive(); // Only return 'text' messages + +$client = new WebSocket\Client("ws://echo.websocket.org/", ['filter' => ['text', 'binary', 'ping', 'pong', 'close']]); +$client->receive(); // Return all messages +``` + +### Sending messages + +There are convenience methods to send messages with different opcodes. +```php +$client = new WebSocket\Client("ws://echo.websocket.org/"); + +// Convenience methods +$client->text('A plain text message'); // Send an opcode=text message +$client->binary($binary_string); // Send an opcode=binary message +$client->ping(); // Send an opcode=ping frame +$client->pong(); // Send an unsolicited opcode=pong frame + +// Generic send method +$client->send($payload); // Sent as masked opcode=text +$client->send($payload, 'binary'); // Sent as masked opcode=binary +$client->send($payload, 'binary', false); // Sent as unmasked opcode=binary +``` + ## Constructor options The `$options` parameter in constructor accepts an associative array of options. -* `timeout` - Time out in seconds. Default 5 seconds. -* `fragment_size` - Maximum payload size. Default 4096 chars. * `context` - A stream context created using [stream_context_create](https://www.php.net/manual/en/function.stream-context-create). +* `filter` - Array of opcodes to return on receive, default `['text', 'binary']` +* `fragment_size` - Maximum payload size. Default 4096 chars. * `headers` - Additional headers as associative array name => content. * `logger` - A [PSR-3](https://www.php-fig.org/psr/psr-3/) compatible logger. * `persistent` - Connection is re-used between requests until time out is reached. Default false. +* `return_obj` - Return a [Message](Message.md) instance on receive, default false +* `timeout` - Time out in seconds. Default 5 seconds. ```php $context = stream_context_create(); @@ -77,12 +117,15 @@ stream_context_set_option($context, 'ssl', 'verify_peer', false); stream_context_set_option($context, 'ssl', 'verify_peer_name', false); $client = new WebSocket\Client("ws://echo.websocket.org/", [ - 'timeout' => 60, // 1 minute time out - 'context' => $context, - 'headers' => [ + 'context' => $context, // Attach stream context created above + 'filter' => ['text', 'binary', 'ping'], // Specify message types for receive() to return + 'headers' => [ // Additional headers, used to specify subprotocol 'Sec-WebSocket-Protocol' => 'soap', 'origin' => 'localhost', ], + 'logger' => $my_psr3_logger, // Attach a PSR3 compatible logger + 'return_obj' => true, // Return Message insatnce rather than just text + 'timeout' => 60, // 1 minute time out ]); ``` diff --git a/docs/Contributing.md b/docs/Contributing.md index 7a9237d..263d868 100644 --- a/docs/Contributing.md +++ b/docs/Contributing.md @@ -1,4 +1,4 @@ -[Client](Client.md) • [Server](Server.md) • [Examples](Examples.md) • [Changelog](Changelog.md) • Contributing +[Client](Client.md) • [Server](Server.md) • [Message](Message.md) • [Examples](Examples.md) • [Changelog](Changelog.md) • Contributing # Websocket: Contributing @@ -34,7 +34,11 @@ make cs-check ## Unit testing -Unit tests with [PHPUnit](https://phpunit.readthedocs.io/). +Unit tests with [PHPUnit](https://phpunit.readthedocs.io/), coverage with [Coveralls](https://github.com/php-coveralls/php-coveralls) ``` # Run unit tests make test + +# Create coverage +make coverage +``` diff --git a/docs/Examples.md b/docs/Examples.md index b5adbfd..7dd4e0c 100644 --- a/docs/Examples.md +++ b/docs/Examples.md @@ -1,4 +1,4 @@ -[Client](Client.md) • [Server](Server.md) • Examples • [Changelog](Changelog.md) • [Contributing](Contributing.md) +[Client](Client.md) • [Server](Server.md) • [Message](Message.md) • Examples • [Changelog](Changelog.md) • [Contributing](Contributing.md) # Websocket: Examples diff --git a/docs/Message.md b/docs/Message.md new file mode 100644 index 0000000..9bd0f2b --- /dev/null +++ b/docs/Message.md @@ -0,0 +1,60 @@ +[Client](Client.md) • [Server](Server.md) • Message • [Examples](Examples.md) • [Changelog](Changelog.md) • [Contributing](Contributing.md) + +# Websocket: Messages + +If option `return_obj` is set to `true` on [client](Client.md) or [server](Server.md), +the `receive()` method will return a Message instance instead of a string. + +Available classes correspond to opcode; +* WebSocket\Message\Text +* WebSocket\Message\Binary +* WebSocket\Message\Ping +* WebSocket\Message\Pong +* WebSocket\Message\Close + +Additionally; +* WebSocket\Message\Message - abstract base class for all messages above +* WebSocket\Message\Factory - Factory class to create Msssage instances + +## Message abstract class synopsis + +```php +WebSocket\Message\Message { + + public __construct(string $payload = '') + public __toString() : string + + public getOpcode() : string + public getLength() : int + public getTimestamp() : DateTime + public getContent() : string + public setContent(string $payload = '') : void + public hasContent() : bool +} +``` + +## Factory class synopsis + +```php +WebSocket\Message\Factory { + + public create(string $opcode, string $payload = '') : Message +} +``` + +## Example + +Receving a Message and echo some methods. + +```php +$client = new WebSocket\Client('ws://echo.websocket.org/', ['return_obj' => true]); +$client->text('Hello WebSocket.org!'); +// Echo return same message as sent +$message = $client->receive(); +echo $message->getOpcode(); // -> "text" +echo $message->getLength(); // -> 20 +echo $message->getContent(); // -> "Hello WebSocket.org!" +echo $message->hasContent(); // -> true +echo $message->getTimestamp()->format('H:i:s'); // -> 19:37:18 +$client->close(); +``` diff --git a/docs/Server.md b/docs/Server.md index ef3e546..7d01a41 100644 --- a/docs/Server.md +++ b/docs/Server.md @@ -1,4 +1,4 @@ -[Client](Client.md) • Server • [Examples](Examples.md) • [Changelog](Changelog.md) • [Contributing](Contributing.md) +[Client](Client.md) • Server • [Message](Message.md) • [Examples](Examples.md) • [Changelog](Changelog.md) • [Contributing](Contributing.md) # Websocket: Server @@ -15,8 +15,13 @@ WebSocket\Server { public __construct(array $options = []) public __destruct() + public __toString() : string public accept() : bool + public text(string $payload) : void + public binary(string $payload) : void + public ping(string $payload = '') : void + public pong(string $payload = '') : void public send(mixed $payload, string $opcode = 'text', bool $masked = true) : void public receive() : mixed public close(int $status = 1000, mixed $message = 'ttfn') : mixed @@ -26,6 +31,8 @@ WebSocket\Server { public getRequest() : array public getHeader(string $header_name) : string|null + public getName() : string|null + public getPier() : string|null public getLastOpcode() : string public getCloseStatus() : int public isConnected() : bool @@ -46,7 +53,7 @@ This example reads a single message from a client, and respond with the same mes $server = new WebSocket\Server(); $server->accept(); $message = $server->receive(); -$server->send($message); +$server->text($message); $server->close(); ``` @@ -70,19 +77,55 @@ while ($server->accept()) { $server->close(); ``` +### Filtering received messages + +By default the `receive()` method return messages of 'text' and 'binary' opcode. +The filter option allows you to specify which message types to return. + +```php +$server = new WebSocket\Server(['filter' => ['text']]); +$server->receive(); // only return 'text' messages + +$server = new WebSocket\Server(['filter' => ['text', 'binary', 'ping', 'pong', 'close']]); +$server->receive(); // return all messages +``` + +### Sending messages + +There are convenience methods to send messages with different opcodes. +```php +$server = new WebSocket\Server(); + +// Convenience methods +$server->text('A plain text message'); // Send an opcode=text message +$server->binary($binary_string); // Send an opcode=binary message +$server->ping(); // Send an opcode=ping frame +$server->pong(); // Send an unsolicited opcode=pong frame + +// Generic send method +$server->send($payload); // Sent as masked opcode=text +$server->send($payload, 'binary'); // Sent as masked opcode=binary +$server->send($payload, 'binary', false); // Sent as unmasked opcode=binary +``` + ## Constructor options The `$options` parameter in constructor accepts an associative array of options. -* `timeout` - Time out in seconds. Default 5 seconds. -* `port` - The server port to listen to. Default 8000. +* `filter` - Array of opcodes to return on receive, default `['text', 'binary']` * `fragment_size` - Maximum payload size. Default 4096 chars. * `logger` - A [PSR-3](https://www.php-fig.org/psr/psr-3/) compatible logger. +* `port` - The server port to listen to. Default 8000. +* `return_obj` - Return a [Message](Message.md) instance on receive, default false +* `timeout` - Time out in seconds. Default 5 seconds. ```php $server = new WebSocket\Server([ + 'filter' => ['text', 'binary', 'ping'], // Specify message types for receive() to return + 'logger' => $my_psr3_logger, // Attach a PSR3 compatible logger + 'port' => 9000, // Listening port + 'return_obj' => true, // Return Message insatnce rather than just text 'timeout' => 60, // 1 minute time out - 'port' => 9000, ]); ``` diff --git a/examples/echoserver.php b/examples/echoserver.php index ef9ffb6..231c4c9 100644 --- a/examples/echoserver.php +++ b/examples/echoserver.php @@ -22,6 +22,7 @@ $options = array_merge([ 'port' => 8000, 'timeout' => 200, + 'filter' => ['text', 'binary', 'ping', 'pong'], ], getopt('', ['port:', 'timeout:', 'debug'])); // If debug mode and logger is available @@ -32,42 +33,55 @@ } // Setting timeout to 200 seconds to make time for all tests and manual runs. -$server = new Server($options); +try { + $server = new Server($options); +} catch (ConnectionException $e) { + echo "> ERROR: {$e->getMessage()}\n"; + die(); +} echo "> Listening to port {$server->getPort()}\n"; -while ($server->accept()) { +// Force quit to close server +while (true) { try { - while (true) { - $message = $server->receive(); - $opcode = $server->getLastOpcode(); - if ($opcode == 'close') { - echo "> Closed connection\n"; - continue; - } - echo "> Got '{$message}' [opcode: {$opcode}]\n"; - - switch ($message) { - case 'exit': - echo "> Client told me to quit. Bye bye.\n"; - $server->close(); - echo "> Close status: {$server->getCloseStatus()}\n"; - exit; - case 'headers': - $server->send(implode("\r\n", $server->getRequest())); - break; - case 'ping': - $server->send($message, 'ping'); - break; - case 'auth': - $auth = $server->getHeader('Authorization'); - $server->send("{$auth} - {$message}", $opcode); - break; - default: - $server->send($message, $opcode); + while ($server->accept()) { + echo "> Accepted on port {$server->getPort()}\n"; + while (true) { + $message = $server->receive(); + $opcode = $server->getLastOpcode(); + if (is_null($message)) { + echo "> Closing connection\n"; + continue 2; + } + echo "> Got '{$message}' [opcode: {$opcode}]\n"; + if (in_array($opcode, ['ping', 'pong'])) { + $server->send($message); + continue; + } + // Allow certain string to trigger server action + switch ($message) { + case 'exit': + echo "> Client told me to quit. Bye bye.\n"; + $server->close(); + echo "> Close status: {$server->getCloseStatus()}\n"; + exit; + case 'headers': + $server->text(implode("\r\n", $server->getRequest())); + break; + case 'ping': + $server->ping($message); + break; + case 'auth': + $auth = $server->getHeader('Authorization'); + $server->text("{$auth} - {$message}"); + break; + default: + $server->text($message); + } } } - } catch (WebSocket\ConnectionException $e) { - echo "\n", microtime(true), " Connection died: $e\n"; + } catch (ConnectionException $e) { + echo "> ERROR: {$e->getMessage()}\n"; } } diff --git a/examples/random_client.php b/examples/random_client.php index e193d0b..b23bd6b 100644 --- a/examples/random_client.php +++ b/examples/random_client.php @@ -59,11 +59,11 @@ switch (rand(1, 10)) { case 1: echo "> Sending text\n"; - $client->send("Text message {$randStr()}", 'text'); + $client->text("Text message {$randStr()}"); break; case 2: echo "> Sending binary\n"; - $client->send("Binary message {$randStr()}", 'binary'); + $client->binary("Binary message {$randStr()}"); break; case 3: echo "> Sending close\n"; @@ -71,11 +71,11 @@ break; case 4: echo "> Sending ping\n"; - $client->send("Ping message {$randStr(8)}", 'ping'); + $client->ping("Ping message {$randStr(8)}"); break; case 5: echo "> Sending pong\n"; - $client->send("Pong message {$randStr(8)}", 'pong'); + $client->pong("Pong message {$randStr(8)}"); break; default: echo "> Receiving\n"; diff --git a/examples/random_server.php b/examples/random_server.php index 82ca81d..0b0849c 100644 --- a/examples/random_server.php +++ b/examples/random_server.php @@ -42,7 +42,7 @@ echo "> Using logger\n"; } -// Main loop +// Force quit to close server while (true) { try { // Setup server @@ -55,34 +55,36 @@ echo "> Creating server {$info}\n"; while ($server->accept()) { - // Random actions - switch (rand(1, 10)) { - case 1: - echo "> Sending text\n"; - $server->send("Text message {$randStr()}", 'text'); - break; - case 2: - echo "> Sending binary\n"; - $server->send("Binary message {$randStr()}", 'binary'); - break; - case 3: - echo "> Sending close\n"; - $server->close(rand(1000, 2000), "Close message {$randStr(8)}"); - break; - case 4: - echo "> Sending ping\n"; - $server->send("Ping message {$randStr(8)}", 'ping'); - break; - case 5: - echo "> Sending pong\n"; - $server->send("Pong message {$randStr(8)}", 'pong'); - break; - default: - echo "> Receiving\n"; - $received = $server->receive(); - echo "> Received {$server->getLastOpcode()}: {$received}\n"; + while (true) { + // Random actions + switch (rand(1, 10)) { + case 1: + echo "> Sending text\n"; + $server->text("Text message {$randStr()}"); + break; + case 2: + echo "> Sending binary\n"; + $server->binary("Binary message {$randStr()}"); + break; + case 3: + echo "> Sending close\n"; + $server->close(rand(1000, 2000), "Close message {$randStr(8)}"); + break; + case 4: + echo "> Sending ping\n"; + $server->ping("Ping message {$randStr(8)}"); + break; + case 5: + echo "> Sending pong\n"; + $server->pong("Pong message {$randStr(8)}"); + break; + default: + echo "> Receiving\n"; + $received = $server->receive(); + echo "> Received {$server->getLastOpcode()}: {$received}\n"; + } + sleep(rand(1, 5)); } - sleep(rand(1, 5)); } } catch (\Throwable $e) { echo "ERROR: {$e->getMessage()} [{$e->getCode()}]\n"; diff --git a/examples/send.php b/examples/send.php index 605918b..30e48e0 100644 --- a/examples/send.php +++ b/examples/send.php @@ -36,9 +36,14 @@ // Create client, send and recevie $client = new Client($options['uri'], $options); $client->send($message, $options['opcode']); - $message = $client->receive(); - $opcode = $client->getLastOpcode(); - echo "> Got '{$message}' [opcode: {$opcode}]\n"; + echo "> Sent '{$message}' [opcode: {$options['opcode']}]\n"; + if (in_array($options['opcode'], ['text', 'binary'])) { + $message = $client->receive(); + $opcode = $client->getLastOpcode(); + if (!is_null($message)) { + echo "> Got '{$message}' [opcode: {$opcode}]\n"; + } + } $client->close(); echo "> Closing client\n"; } catch (\Throwable $e) { diff --git a/lib/Base.php b/lib/Base.php index 69021fb..2c84489 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -9,9 +9,8 @@ namespace WebSocket; -use Psr\Log\LoggerAwareInterface; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; +use Psr\Log\{LoggerAwareInterface, LoggerInterface, NullLogger}; +use WebSocket\Message\Factory; class Base implements LoggerAwareInterface { @@ -21,6 +20,7 @@ class Base implements LoggerAwareInterface protected $last_opcode = null; protected $close_status = null; protected $logger; + private $read_buffer; protected static $opcodes = [ 'continuation' => 0, @@ -48,7 +48,7 @@ public function isConnected(): bool get_resource_type($this->socket) == 'persistent stream'); } - public function setTimeout($timeout): void + public function setTimeout(int $timeout): void { $this->options['timeout'] = $timeout; @@ -57,7 +57,7 @@ public function setTimeout($timeout): void } } - public function setFragmentSize($fragment_size): self + public function setFragmentSize(int $fragment_size): self { $this->options['fragment_size'] = $fragment_size; return $this; @@ -73,7 +73,7 @@ public function setLogger(LoggerInterface $logger = null): void $this->logger = $logger ?: new NullLogger(); } - public function send($payload, $opcode = 'text', $masked = true): void + public function send(string $payload, string $opcode = 'text', bool $masked = true): void { if (!$this->isConnected()) { $this->connect(); @@ -98,43 +98,105 @@ public function send($payload, $opcode = 'text', $masked = true): void $frame_opcode = 'continuation'; } - $this->logger->info("Sent '{$opcode}' message"); + $this->logger->info("Sent '{$opcode}' message", [ + 'opcode' => $opcode, + 'content-length' => strlen($payload), + 'frames' => count($payload_chunks), + ]); } - protected function sendFragment($final, $payload, $opcode, $masked): void + /** + * Convenience method to send text message + * @param string $payload Content as string + */ + public function text(string $payload): void { - // Binary string for header. - $frame_head_binstr = ''; + $this->send($payload); + } - // Write FIN, final fragment bit. - $frame_head_binstr .= (bool) $final ? '1' : '0'; + /** + * Convenience method to send binary message + * @param string $payload Content as binary string + */ + public function binary(string $payload): void + { + $this->send($payload, 'binary'); + } - // RSV 1, 2, & 3 false and unused. - $frame_head_binstr .= '000'; + /** + * Convenience method to send ping + * @param string $payload Optional text as string + */ + public function ping(string $payload = ''): void + { + $this->send($payload, 'ping'); + } - // Opcode rest of the byte. - $frame_head_binstr .= sprintf('%04b', self::$opcodes[$opcode]); + /** + * Convenience method to send unsolicited pong + * @param string $payload Optional text as string + */ + public function pong(string $payload = ''): void + { + $this->send($payload, 'pong'); + } - // Use masking? - $frame_head_binstr .= $masked ? '1' : '0'; + /** + * Get name of local socket, or null if not connected + * @return string|null + */ + public function getName(): ?string + { + return $this->isConnected() ? stream_socket_get_name($this->socket) : null; + } + + /** + * Get name of remote socket, or null if not connected + * @return string|null + */ + public function getPier(): ?string + { + return $this->isConnected() ? stream_socket_get_name($this->socket, true) : null; + } + + /** + * Get string representation of instance + * @return string String representation + */ + public function __toString(): string + { + return sprintf( + "%s(%s)", + get_class($this), + $this->getName() ?: 'closed' + ); + } + + /** + * Receive one message. + * Will continue reading until read message match filter settings. + * Return Message instance or string according to settings. + */ + protected function sendFragment(bool $final, string $payload, string $opcode, bool $masked): void + { + $data = ''; + + $byte_1 = $final ? 0b10000000 : 0b00000000; // Final fragment marker. + $byte_1 |= self::$opcodes[$opcode]; // Set opcode. + $data .= pack('C', $byte_1); + + $byte_2 = $masked ? 0b10000000 : 0b00000000; // Masking bit marker. // 7 bits of payload length... $payload_length = strlen($payload); if ($payload_length > 65535) { - $frame_head_binstr .= decbin(127); - $frame_head_binstr .= sprintf('%064b', $payload_length); + $data .= pack('C', $byte_2 | 0b01111111); + $data .= pack('J', $payload_length); } elseif ($payload_length > 125) { - $frame_head_binstr .= decbin(126); - $frame_head_binstr .= sprintf('%016b', $payload_length); + $data .= pack('C', $byte_2 | 0b01111110); + $data .= pack('n', $payload_length); } else { - $frame_head_binstr .= sprintf('%07b', $payload_length); - } - - $frame = ''; - - // Write frame head to frame. - foreach (str_split($frame_head_binstr, 8) as $binstr) { - $frame .= chr(bindec($binstr)); + $data .= pack('C', $byte_2 | $payload_length); } // Handle masking @@ -144,48 +206,89 @@ protected function sendFragment($final, $payload, $opcode, $masked): void for ($i = 0; $i < 4; $i++) { $mask .= chr(rand(0, 255)); } - $frame .= $mask; + $data .= $mask; } // Append payload to frame: for ($i = 0; $i < $payload_length; $i++) { - $frame .= ($masked === true) ? $payload[$i] ^ $mask[$i % 4] : $payload[$i]; + $data .= ($masked === true) ? $payload[$i] ^ $mask[$i % 4] : $payload[$i]; } - - $this->write($frame); + $this->write($data); + $this->logger->debug("Sent '{$opcode}' frame", [ + 'opcode' => $opcode, + 'final' => $final, + 'content-length' => strlen($payload), + ]); } - public function receive(): string + public function receive() { + $filter = $this->options['filter']; if (!$this->isConnected()) { $this->connect(); } - $payload = ''; do { $response = $this->receiveFragment(); - $payload .= $response[0]; - } while (!$response[1]); + list ($payload, $final, $opcode) = $response; - $this->logger->info("Received '{$this->last_opcode}' message"); - return $payload; + // Continuation and factual opcode + $continuation = ($opcode == 'continuation'); + $payload_opcode = $continuation ? $this->read_buffer['opcode'] : $opcode; + + // Filter frames + if (!in_array($payload_opcode, $filter)) { + if ($payload_opcode == 'close') { + return null; // Always abort receive on close + } + $final = false; + continue; // Continue reading + } + + // First continuation frame, create buffer + if (!$final && !$continuation) { + $this->read_buffer = ['opcode' => $opcode, 'payload' => $payload, 'frames' => 1]; + continue; // Continue reading + } + + // Subsequent continuation frames, add to buffer + if ($continuation) { + $this->read_buffer['payload'] .= $payload; + $this->read_buffer['frames']++; + } + } while (!$final); + + // Final, return payload + $frames = 1; + if ($continuation) { + $payload = $this->read_buffer['payload']; + $frames = $this->read_buffer['frames']; + $this->read_buffer = null; + } + $this->logger->info("Received '{opcode}' message", [ + 'opcode' => $payload_opcode, + 'content-length' => strlen($payload), + 'frames' => $frames, + ]); + + $this->last_opcode = $payload_opcode; + $factory = new Factory(); + return $this->options['return_obj'] + ? $factory->create($payload_opcode, $payload) + : $payload; } protected function receiveFragment(): array { - // Just read the main fragment information first. + // Read the fragment "header" first, two bytes. $data = $this->read(2); + list ($byte_1, $byte_2) = array_values(unpack('C*', $data)); - // Is this the final fragment? // Bit 0 in byte 0 - $final = (bool) (ord($data[0]) & 1 << 7); - - // Should be unused, and must be false… // Bits 1, 2, & 3 - $rsv1 = (bool) (ord($data[0]) & 1 << 6); - $rsv2 = (bool) (ord($data[0]) & 1 << 5); - $rsv3 = (bool) (ord($data[0]) & 1 << 4); + $final = (bool)($byte_1 & 0b10000000); // Final fragment marker. + $rsv = $byte_1 & 0b01110000; // Unused bits, ignore // Parse opcode - $opcode_int = ord($data[0]) & 15; // Bits 4-7 + $opcode_int = $byte_1 & 0b00001111; $opcode_ints = array_flip(self::$opcodes); if (!array_key_exists($opcode_int, $opcode_ints)) { $warning = "Bad opcode in websocket frame: {$opcode_int}"; @@ -194,20 +297,22 @@ protected function receiveFragment(): array } $opcode = $opcode_ints[$opcode_int]; - // Masking? - $mask = (bool) (ord($data[1]) >> 7); // Bit 0 in byte 1 + // Masking bit + $mask = (bool)($byte_2 & 0b10000000); $payload = ''; // Payload length - $payload_length = (int) ord($data[1]) & 127; // Bits 1-7 in byte 1 + $payload_length = $byte_2 & 0b01111111; + if ($payload_length > 125) { if ($payload_length === 126) { $data = $this->read(2); // 126: Payload is a 16-bit unsigned int + $payload_length = current(unpack('n', $data)); } else { $data = $this->read(8); // 127: Payload is a 64-bit unsigned int + $payload_length = current(unpack('J', $data)); } - $payload_length = bindec(self::sprintB($data)); } // Get masking key. @@ -229,34 +334,37 @@ protected function receiveFragment(): array } } + $this->logger->debug("Read '{opcode}' frame", [ + 'opcode' => $opcode, + 'final' => $final, + 'content-length' => strlen($payload), + ]); + // if we received a ping, send a pong and wait for the next message if ($opcode === 'ping') { $this->logger->debug("Received 'ping', sending 'pong'."); $this->send($payload, 'pong', true); - return [null, false]; + return [$payload, true, $opcode]; } // if we received a pong, wait for the next message if ($opcode === 'pong') { $this->logger->debug("Received 'pong'."); - return [null, false]; - } - - // Record the opcode if we are not receiving a continutation fragment - if ($opcode !== 'continuation') { - $this->last_opcode = $opcode; + return [$payload, true, $opcode]; } if ($opcode === 'close') { + $status_bin = ''; + $status = ''; // Get the close status. $status_bin = ''; $status = ''; if ($payload_length > 0) { $status_bin = $payload[0] . $payload[1]; - $status = bindec(sprintf("%08b%08b", ord($payload[0]), ord($payload[1]))); + $status = current(unpack('n', $payload)); $this->close_status = $status; } - // Get additional close message- + // Get additional close message if ($payload_length >= 2) { $payload = substr($payload, 2); } @@ -273,10 +381,10 @@ protected function receiveFragment(): array fclose($this->socket); // Closing should not return message. - return [null, true]; + return [$payload, true, $opcode]; } - return [$payload, $final]; + return [$payload, $final, $opcode]; } /** @@ -285,7 +393,7 @@ protected function receiveFragment(): array * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4 * @param string $message A closing message, max 125 bytes. */ - public function close($status = 1000, $message = 'ttfn'): void + public function close(int $status = 1000, string $message = 'ttfn'): void { if (!$this->isConnected()) { return; @@ -302,7 +410,7 @@ public function close($status = 1000, $message = 'ttfn'): void $this->receive(); // Receiving a close frame will close the socket now. } - protected function write($data): void + protected function write(string $data): void { $length = strlen($data); $written = fwrite($this->socket, $data); @@ -315,7 +423,7 @@ protected function write($data): void $this->logger->debug("Wrote {$written} of {$length} bytes."); } - protected function read($length): string + protected function read(string $length): string { $data = ''; while (strlen($data) < $length) { @@ -328,36 +436,29 @@ protected function read($length): string $this->throwException("Empty read; connection dead?"); } $data .= $buffer; + $read = strlen($data); + $this->logger->debug("Read {$read} of {$length} bytes."); } return $data; } - protected function throwException($message, $code = 0): void + protected function throwException(string $message, int $code = 0): void { - $meta = $this->isConnected() ? stream_get_meta_data($this->socket) : []; + $meta = ['closed' => true]; + if ($this->isConnected()) { + $meta = stream_get_meta_data($this->socket); + fclose($this->socket); + $this->socket = null; + } $json_meta = json_encode($meta); - fclose($this->socket); if (!empty($meta['timed_out'])) { - $code = ConnectionException::TIMED_OUT; - $this->logger->warning("{$message}", (array)$meta); - throw new TimeoutException("{$message} Stream state: {$json_meta}", $code); + $this->logger->error($message, $meta); + throw new TimeoutException($message, ConnectionException::TIMED_OUT, $meta); } if (!empty($meta['eof'])) { $code = ConnectionException::EOF; } - $this->logger->error("{$message}", (array)$meta); - throw new ConnectionException("{$message} Stream state: {$json_meta}", $code); - } - - /** - * Helper to convert a binary to a string of '0' and '1'. - */ - protected static function sprintB($string): string - { - $return = ''; - for ($i = 0; $i < strlen($string); $i++) { - $return .= sprintf("%08b", ord($string[$i])); - } - return $return; + $this->logger->error($message, $meta); + throw new ConnectionException($message, $code, $meta); } } diff --git a/lib/Client.php b/lib/Client.php index 5a9ec09..c85c706 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -13,13 +13,15 @@ class Client extends Base { // Default options protected static $default_options = [ - 'persistent' => false, - 'timeout' => 5, - 'fragment_size' => 4096, 'context' => null, + 'filter' => ['text', 'binary'], + 'fragment_size' => 4096, 'headers' => null, 'logger' => null, 'origin' => null, // @deprecated + 'persistent' => false, + 'return_obj' => false, + 'timeout' => 5, ]; protected $socket_uri; @@ -33,7 +35,7 @@ class Client extends Base * - fragment_size: Set framgemnt size. Default: 4096 * - headers: Associative array of headers to set/override. */ - public function __construct($uri, $options = []) + public function __construct(string $uri, array $options = []) { $this->options = array_merge(self::$default_options, $options); $this->socket_uri = $uri; @@ -101,9 +103,15 @@ protected function connect(): void $flags = STREAM_CLIENT_CONNECT; $flags = ($this->options['persistent'] === true) ? $flags | STREAM_CLIENT_PERSISTENT : $flags; - // Open the socket. @ is there to supress warning that we will catch in check below instead. - $this->socket = @stream_socket_client( - $host_uri . ':' . $port, + $error = $errno = $errstr = null; + set_error_handler(function (int $severity, string $message, string $file, int $line) use (&$error) { + $this->logger->warning($message, ['severity' => $severity]); + $error = $message; + }, E_ALL); + + // Open the socket. + $this->socket = stream_socket_client( + "{$host_uri}:{$port}", $errno, $errstr, $this->options['timeout'], @@ -111,8 +119,10 @@ protected function connect(): void $context ); + restore_error_handler(); + if (!$this->isConnected()) { - $error = "Could not open socket to \"{$host}:{$port}\": {$errstr} ({$errno})."; + $error = "Could not open socket to \"{$host}:{$port}\": {$errstr} ({$errno}) {$error}."; $this->logger->error($error); throw new ConnectionException($error); } @@ -196,11 +206,9 @@ function ($key, $value) { */ protected static function generateKey(): string { - $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789'; $key = ''; - $chars_length = strlen($chars); for ($i = 0; $i < 16; $i++) { - $key .= $chars[mt_rand(0, $chars_length - 1)]; + $key .= chr(rand(33, 126)); } return base64_encode($key); } diff --git a/lib/ConnectionException.php b/lib/ConnectionException.php index b20932f..7e1ecbf 100644 --- a/lib/ConnectionException.php +++ b/lib/ConnectionException.php @@ -2,10 +2,25 @@ namespace WebSocket; +use Throwable; + class ConnectionException extends Exception { // Native codes in interval 0-106 public const TIMED_OUT = 1024; public const EOF = 1025; public const BAD_OPCODE = 1026; + + private $data; + + public function __construct(string $message, int $code = 0, array $data = [], Throwable $prev = null) + { + parent::__construct($message, $code, $prev); + $this->data = $data; + } + + public function getData(): array + { + return $this->data; + } } diff --git a/lib/Message/Binary.php b/lib/Message/Binary.php new file mode 100644 index 0000000..84d27f5 --- /dev/null +++ b/lib/Message/Binary.php @@ -0,0 +1,8 @@ +payload = $payload; + $this->timestamp = new DateTime(); + } + + public function getOpcode(): string + { + return $this->opcode; + } + + public function getLength(): int + { + return strlen($this->payload); + } + + public function getTimestamp(): DateTime + { + return $this->timestamp; + } + + public function getContent(): string + { + return $this->payload; + } + + public function setContent(string $payload = ''): void + { + $this->payload = $payload; + } + + public function hasContent(): bool + { + return $this->payload != ''; + } + + public function __toString(): string + { + return get_class($this); + } +} diff --git a/lib/Message/Ping.php b/lib/Message/Ping.php new file mode 100644 index 0000000..908d233 --- /dev/null +++ b/lib/Message/Ping.php @@ -0,0 +1,8 @@ + null, + 'filter' => ['text', 'binary'], 'fragment_size' => 4096, - 'port' => 8000, 'logger' => null, + 'port' => 8000, + 'return_obj' => false, + 'timeout' => null, ]; protected $addr; @@ -38,14 +40,22 @@ public function __construct(array $options = []) $this->port = $this->options['port']; $this->setLogger($this->options['logger']); + $error = $errno = $errstr = null; + set_error_handler(function (int $severity, string $message, string $file, int $line) use (&$error) { + $this->logger->warning($message, ['severity' => $severity]); + $error = $message; + }, E_ALL); + do { - $this->listening = @stream_socket_server("tcp://0.0.0.0:$this->port", $errno, $errstr); + $this->listening = stream_socket_server("tcp://0.0.0.0:$this->port", $errno, $errstr); } while ($this->listening === false && $this->port++ < 10000); + restore_error_handler(); + if (!$this->listening) { - $error = "Could not open listening socket: {$errstr} ({$errno})"; + $error = "Could not open listening socket: {$errstr} ({$errno}) {$error}"; $this->logger->error($error); - throw new ConnectionException($error, $errno); + throw new ConnectionException($error, (int)$errno); } $this->logger->info("Server listening to port {$this->port}"); @@ -93,24 +103,32 @@ public function accept(): bool protected function connect(): void { - if (empty($this->options['timeout'])) { - $this->socket = @stream_socket_accept($this->listening); - if (!$this->socket) { - $error = 'Server failed to connect.'; - $this->logger->error($error); - throw new ConnectionException($error); - } + + $error = null; + set_error_handler(function (int $severity, string $message, string $file, int $line) use (&$error) { + $this->logger->warning($message, ['severity' => $severity]); + $error = $message; + }, E_ALL); + + if (isset($this->options['timeout'])) { + $this->socket = stream_socket_accept($this->listening, $this->options['timeout']); } else { - $this->socket = @stream_socket_accept($this->listening, $this->options['timeout']); - if (!$this->socket) { - $error = 'Server failed to connect.'; - $this->logger->error($error); - throw new ConnectionException($error); - } + $this->socket = stream_socket_accept($this->listening); + } + + restore_error_handler(); + + if (!$this->socket) { + $this->throwException("Server failed to connect. {$error}"); + } + if (isset($this->options['timeout'])) { stream_set_timeout($this->socket, $this->options['timeout']); } - $this->logger->info("Client has connected to port {$this->port}"); + $this->logger->info("Client has connected to port {port}", [ + 'port' => $this->port, + 'pier' => stream_socket_get_name($this->socket, true), + ]); $this->performHandshake(); } diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 4b6f48f..d3ab410 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -40,12 +40,6 @@ public function testClientMasked(): void $client->close(); $this->assertFalse($client->isConnected()); $this->assertEquals(1000, $client->getCloseStatus()); - $this->assertEquals('close', $client->getLastOpcode()); - - $client->close(); - $this->assertFalse($client->isConnected()); - $this->assertEquals(1000, $client->getCloseStatus()); - $this->assertEquals('close', $client->getLastOpcode()); $this->assertTrue(MockSocket::isEmpty()); } @@ -179,11 +173,11 @@ public function testRemoteClose(): void MockSocket::initialize('close-remote', $this); $message = $client->receive(); - $this->assertEquals('', $message); + $this->assertNull($message); $this->assertFalse($client->isConnected()); $this->assertEquals(17260, $client->getCloseStatus()); - $this->assertEquals('close', $client->getLastOpcode()); + $this->assertNull($client->getLastOpcode()); $this->assertTrue(MockSocket::isEmpty()); } @@ -213,7 +207,7 @@ public function testReconnect(): void $client->close(); $this->assertFalse($client->isConnected()); $this->assertEquals(1000, $client->getCloseStatus()); - $this->assertEquals('close', $client->getLastOpcode()); + $this->assertNull($client->getLastOpcode()); $this->assertTrue(MockSocket::isEmpty()); MockSocket::initialize('client.reconnect', $this); @@ -267,6 +261,16 @@ public function testFailedConnection(): void $client->send('Connect'); } + public function testFailedConnectionWithError(): void + { + MockSocket::initialize('client.connect-error', $this); + $client = new Client('ws://localhost:8000/my/mock/path'); + $this->expectException('WebSocket\ConnectionException'); + $this->expectExceptionCode(0); + $this->expectExceptionMessage('Could not open socket to "localhost:8000"'); + $client->send('Connect'); + } + public function testInvalidUpgrade(): void { MockSocket::initialize('client.connect-invalid-upgrade', $this); @@ -358,4 +362,74 @@ public function testEmptyRead(): void $this->expectExceptionMessage('Empty read; connection dead?'); $client->receive(); } + + public function testFrameFragmentation(): void + { + MockSocket::initialize('client.connect', $this); + $client = new Client( + 'ws://localhost:8000/my/mock/path', + ['filter' => ['text', 'binary', 'pong', 'close']] + ); + $client->send('Connect'); + MockSocket::initialize('receive-fragmentation', $this); + $message = $client->receive(); + $this->assertEquals('Server ping', $message); + $this->assertEquals('pong', $client->getLastOpcode()); + $message = $client->receive(); + $this->assertEquals('Multi fragment test', $message); + $this->assertEquals('text', $client->getLastOpcode()); + $this->assertTrue(MockSocket::isEmpty()); + MockSocket::initialize('close-remote', $this); + $message = $client->receive(); + $this->assertEquals('Closing', $message); + $this->assertTrue(MockSocket::isEmpty()); + $this->assertFalse($client->isConnected()); + $this->assertEquals(17260, $client->getCloseStatus()); + $this->assertEquals('close', $client->getLastOpcode()); + } + + public function testMessageFragmentation(): void + { + MockSocket::initialize('client.connect', $this); + $client = new Client( + 'ws://localhost:8000/my/mock/path', + ['filter' => ['text', 'binary', 'pong', 'close'], 'return_obj' => true] + ); + $client->send('Connect'); + MockSocket::initialize('receive-fragmentation', $this); + $message = $client->receive(); + $this->assertInstanceOf('WebSocket\Message\Message', $message); + $this->assertInstanceOf('WebSocket\Message\Pong', $message); + $this->assertEquals('Server ping', $message->getContent()); + $this->assertEquals('pong', $message->getOpcode()); + $message = $client->receive(); + $this->assertInstanceOf('WebSocket\Message\Message', $message); + $this->assertInstanceOf('WebSocket\Message\Text', $message); + $this->assertEquals('Multi fragment test', $message->getContent()); + $this->assertEquals('text', $message->getOpcode()); + $this->assertTrue(MockSocket::isEmpty()); + MockSocket::initialize('close-remote', $this); + $message = $client->receive(); + $this->assertInstanceOf('WebSocket\Message\Message', $message); + $this->assertInstanceOf('WebSocket\Message\Close', $message); + $this->assertEquals('Closing', $message->getContent()); + $this->assertEquals('close', $message->getOpcode()); + } + + public function testConvenicanceMethods(): void + { + MockSocket::initialize('client.connect', $this); + $client = new Client('ws://localhost:8000/my/mock/path'); + $this->assertNull($client->getName()); + $this->assertNull($client->getPier()); + $this->assertEquals('WebSocket\Client(closed)', "{$client}"); + $client->text('Connect'); + MockSocket::initialize('send-convenicance', $this); + $client->binary(base64_encode('Binary content')); + $client->ping(); + $client->pong(); + $this->assertEquals('127.0.0.1:12345', $client->getName()); + $this->assertEquals('127.0.0.1:8000', $client->getPier()); + $this->assertEquals('WebSocket\Client(127.0.0.1:12345)', "{$client}"); + } } diff --git a/tests/ExceptionTest.php b/tests/ExceptionTest.php new file mode 100644 index 0000000..84b939c --- /dev/null +++ b/tests/ExceptionTest.php @@ -0,0 +1,52 @@ + 'with data'], + new TimeoutException( + 'Nested exception', + ConnectionException::TIMED_OUT + ) + ); + } catch (Throwable $e) { + } + + $this->assertInstanceOf('WebSocket\ConnectionException', $e); + $this->assertInstanceOf('WebSocket\Exception', $e); + $this->assertInstanceOf('Exception', $e); + $this->assertInstanceOf('Throwable', $e); + $this->assertEquals('An error message', $e->getMessage()); + $this->assertEquals(1025, $e->getCode()); + $this->assertEquals(['test' => 'with data'], $e->getData()); + + $p = $e->getPrevious(); + $this->assertInstanceOf('WebSocket\TimeoutException', $p); + $this->assertInstanceOf('WebSocket\ConnectionException', $p); + $this->assertEquals('Nested exception', $p->getMessage()); + $this->assertEquals(1024, $p->getCode()); + $this->assertEquals([], $p->getData()); + } +} diff --git a/tests/MessageTest.php b/tests/MessageTest.php new file mode 100644 index 0000000..2d06ab7 --- /dev/null +++ b/tests/MessageTest.php @@ -0,0 +1,61 @@ +create('text', 'Some content'); + $this->assertInstanceOf('WebSocket\Message\Text', $message); + $message = $factory->create('binary', 'Some content'); + $this->assertInstanceOf('WebSocket\Message\Binary', $message); + $message = $factory->create('ping', 'Some content'); + $this->assertInstanceOf('WebSocket\Message\Ping', $message); + $message = $factory->create('pong', 'Some content'); + $this->assertInstanceOf('WebSocket\Message\Pong', $message); + $message = $factory->create('close', 'Some content'); + $this->assertInstanceOf('WebSocket\Message\Close', $message); + } + + public function testMessage() + { + $message = new Text('Some content'); + $this->assertInstanceOf('WebSocket\Message\Message', $message); + $this->assertInstanceOf('WebSocket\Message\Text', $message); + $this->assertEquals('Some content', $message->getContent()); + $this->assertEquals('text', $message->getOpcode()); + $this->assertEquals(12, $message->getLength()); + $this->assertTrue($message->hasContent()); + $this->assertInstanceOf('DateTime', $message->getTimestamp()); + $message->setContent(''); + $this->assertEquals(0, $message->getLength()); + $this->assertFalse($message->hasContent()); + $this->assertEquals('WebSocket\Message\Text', "{$message}"); + } + + public function testBadOpcode() + { + $factory = new Factory(); + $this->expectException('WebSocket\BadOpcodeException'); + $this->expectExceptionMessage("Invalid opcode 'invalid' provided"); + $message = $factory->create('invalid', 'Some content'); + } +} diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 03fad0a..8294236 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -24,7 +24,6 @@ public function testServerMasked(): void MockSocket::initialize('server.construct', $this); $server = new Server(); $this->assertTrue(MockSocket::isEmpty()); - MockSocket::initialize('server.accept', $this); $server->accept(); $server->send('Connect'); @@ -60,7 +59,6 @@ public function testServerMasked(): void $server->close(); $this->assertFalse($server->isConnected()); $this->assertEquals(1000, $server->getCloseStatus()); - $this->assertEquals('close', $server->getLastOpcode()); $this->assertTrue(MockSocket::isEmpty()); $server->close(); // Already closed @@ -192,7 +190,7 @@ public function testRemoteClose(): void $this->assertTrue(MockSocket::isEmpty()); $this->assertFalse($server->isConnected()); $this->assertEquals(17260, $server->getCloseStatus()); - $this->assertEquals('close', $server->getLastOpcode()); + $this->assertNull($server->getLastOpcode()); } public function testSetTimeout(): void @@ -222,6 +220,15 @@ public function testFailedSocketServer(): void $server = new Server(['port' => 9999]); } + public function testFailedSocketServerWithError(): void + { + MockSocket::initialize('server.construct-error-socket-server', $this); + $this->expectException('WebSocket\ConnectionException'); + $this->expectExceptionCode(0); + $this->expectExceptionMessage('Could not open listening socket:'); + $server = new Server(['port' => 9999]); + } + public function testFailedConnect(): void { MockSocket::initialize('server.construct', $this); @@ -235,6 +242,19 @@ public function testFailedConnect(): void $server->send('Connect'); } + public function testFailedConnectWithError(): void + { + MockSocket::initialize('server.construct', $this); + $server = new Server(); + + MockSocket::initialize('server.accept-error-connect', $this); + $server->accept(); + $this->expectException('WebSocket\ConnectionException'); + $this->expectExceptionCode(0); + $this->expectExceptionMessage('Server failed to connect'); + $server->send('Connect'); + } + public function testFailedConnectTimeout(): void { MockSocket::initialize('server.construct', $this); @@ -354,4 +374,75 @@ public function testEmptyRead(): void $this->expectExceptionMessage('Empty read; connection dead?'); $server->receive(); } + + public function testFrameFragmentation(): void + { + MockSocket::initialize('server.construct', $this); + $server = new Server(['filter' => ['text', 'binary', 'pong', 'close']]); + MockSocket::initialize('server.accept', $this); + $server->accept(); + $server->send('Connect'); + MockSocket::initialize('receive-fragmentation', $this); + $message = $server->receive(); + $this->assertEquals('Server ping', $message); + $this->assertEquals('pong', $server->getLastOpcode()); + $message = $server->receive(); + $this->assertEquals('Multi fragment test', $message); + $this->assertEquals('text', $server->getLastOpcode()); + $this->assertTrue(MockSocket::isEmpty()); + MockSocket::initialize('close-remote', $this); + $message = $server->receive(); + $this->assertEquals('Closing', $message); + $this->assertTrue(MockSocket::isEmpty()); + $this->assertFalse($server->isConnected()); + $this->assertEquals(17260, $server->getCloseStatus()); + $this->assertEquals('close', $server->getLastOpcode()); + } + + public function testMessageFragmentation(): void + { + MockSocket::initialize('server.construct', $this); + $server = new Server(['filter' => ['text', 'binary', 'pong', 'close'], 'return_obj' => true]); + MockSocket::initialize('server.accept', $this); + $server->accept(); + $server->send('Connect'); + MockSocket::initialize('receive-fragmentation', $this); + $message = $server->receive(); + $this->assertInstanceOf('WebSocket\Message\Message', $message); + $this->assertInstanceOf('WebSocket\Message\Pong', $message); + $this->assertEquals('Server ping', $message->getContent()); + $this->assertEquals('pong', $message->getOpcode()); + $message = $server->receive(); + $this->assertInstanceOf('WebSocket\Message\Message', $message); + $this->assertInstanceOf('WebSocket\Message\Text', $message); + $this->assertEquals('Multi fragment test', $message->getContent()); + $this->assertEquals('text', $message->getOpcode()); + $this->assertTrue(MockSocket::isEmpty()); + MockSocket::initialize('close-remote', $this); + $message = $server->receive(); + $this->assertInstanceOf('WebSocket\Message\Message', $message); + $this->assertInstanceOf('WebSocket\Message\Close', $message); + $this->assertEquals('Closing', $message->getContent()); + $this->assertEquals('close', $message->getOpcode()); + } + + public function testConvenicanceMethods(): void + { + MockSocket::initialize('server.construct', $this); + $server = new Server(); + $this->assertNull($server->getName()); + $this->assertNull($server->getPier()); + $this->assertEquals('WebSocket\Server(closed)', "{$server}"); + MockSocket::initialize('server.accept', $this); + $server->accept(); + $server->text('Connect'); + MockSocket::initialize('send-convenicance', $this); + $server->binary(base64_encode('Binary content')); + $server->ping(); + $server->pong(); + $this->assertEquals('127.0.0.1:12345', $server->getName()); + $this->assertEquals('127.0.0.1:8000', $server->getPier()); + $this->assertEquals('WebSocket\Server(127.0.0.1:12345)', "{$server}"); + $this->assertTrue(MockSocket::isEmpty()); + } } diff --git a/tests/mock/EchoLog.php b/tests/mock/EchoLog.php index ea3280b..369131a 100644 --- a/tests/mock/EchoLog.php +++ b/tests/mock/EchoLog.php @@ -13,7 +13,8 @@ class EchoLog implements \Psr\Log\LoggerInterface public function log($level, $message, array $context = []) { $message = $this->interpolate($message, $context); - echo str_pad($level, 8) . " | $message " . json_encode($context) . "\n"; + $context_string = empty($context) ? '' : json_encode($context); + echo str_pad($level, 8) . " | {$message} {$context_string}\n"; } public function interpolate($message, array $context = []) diff --git a/tests/mock/MockSocket.php b/tests/mock/MockSocket.php index 279b3ea..e12d6ed 100644 --- a/tests/mock/MockSocket.php +++ b/tests/mock/MockSocket.php @@ -20,10 +20,14 @@ public static function handle($function, $params = []) if ($function == 'get_resource_type' && is_null($current)) { return null; // Catch destructors } - self::$asserter->assertEquals($function, $current['function']); + self::$asserter->assertEquals($current['function'], $function); foreach ($current['params'] as $index => $param) { self::$asserter->assertEquals($param, $params[$index], json_encode([$current, $params])); } + if (isset($current['error'])) { + $map = array_merge(['msg' => 'Error', 'type' => E_USER_NOTICE], (array)$current['error']); + trigger_error($map['msg'], $map['type']); + } if (isset($current['return-op'])) { return self::op($current['return-op'], $params, $current['return']); } diff --git a/tests/mock/mock-socket.php b/tests/mock/mock-socket.php index db47fe2..139f828 100644 --- a/tests/mock/mock-socket.php +++ b/tests/mock/mock-socket.php @@ -66,3 +66,8 @@ function get_resource_type() $args = func_get_args(); return MockSocket::handle('get_resource_type', $args); } +function stream_socket_get_name() +{ + $args = func_get_args(); + return MockSocket::handle('stream_socket_get_name', $args); +} diff --git a/tests/scripts/client.connect-error.json b/tests/scripts/client.connect-error.json new file mode 100644 index 0000000..e6c523d --- /dev/null +++ b/tests/scripts/client.connect-error.json @@ -0,0 +1,23 @@ +[ + { + "function": "stream_context_create", + "params": [], + "return": "@mock-stream-context" + }, + { + "function": "stream_socket_client", + "params": [ + "tcp:\/\/localhost:8000", + null, + null, + 5, + 4, + "@mock-stream-context" + ], + "error": { + "msg": "A PHP error", + "type": 512 + }, + "return": false + } +] \ No newline at end of file diff --git a/tests/scripts/receive-fragmentation.json b/tests/scripts/receive-fragmentation.json new file mode 100644 index 0000000..5ae9572 --- /dev/null +++ b/tests/scripts/receive-fragmentation.json @@ -0,0 +1,126 @@ +[ + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "stream" + }, + { + "function": "fread", + "params": [ + "@mock-stream", + 2 + ], + "return-op": "chr-array", + "return": [1, 136] + }, + { + "function": "fread", + "params": [ + "@mock-stream", + 4 + ], + "return-op": "chr-array", + "return": [105, 29, 187, 18] + }, + { + "function": "fread", + "params": [ + "@mock-stream", + 8 + ], + "return-op": "chr-array", + "return": [36, 104, 215, 102, 0, 61, 221, 96] + }, + + { + "function": "fread", + "params": [ + "@mock-stream", + 2 + ], + "return-op": "chr-array", + "return": [138, 139] + }, + { + "function": "fread", + "params": [ + "@mock-stream", + 4 + ], + "return-op": "chr-array", + "return": [1, 1, 1, 1] + }, + { + "function": "fread", + "params": [ + "@mock-stream", + 11 + ], + "return-op": "chr-array", + "return": [82, 100, 115, 119, 100, 115, 33, 113, 104, 111, 102] + }, + + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "stream" + }, + { + "function": "fread", + "params": [ + "@mock-stream", + 2 + ], + "return-op": "chr-array", + "return": [0, 136] + }, + { + "function": "fread", + "params": [ + "@mock-stream", + 4 + ], + "return-op": "chr-array", + "return": [221, 240, 46, 69] + }, + { + "function": "fread", + "params": [ + "@mock-stream", + 8 + ], + "return-op": "chr-array", + "return": [188, 151, 67, 32, 179, 132, 14, 49] + }, + { + "function": "fread", + "params": [ + "@mock-stream", + 2 + ], + "return-op": "chr-array", + "return": [128, 131] + }, + { + "function": "fread", + "params": [ + "@mock-stream", + 4 + ], + "return-op": "chr-array", + "return": [9, 60, 117, 193] + }, + { + "function": "fread", + "params": [ + "@mock-stream", + 3 + ], + "return-op": "chr-array", + "return": [108, 79, 1] + } +] \ No newline at end of file diff --git a/tests/scripts/send-convenicance.json b/tests/scripts/send-convenicance.json new file mode 100644 index 0000000..194cb5a --- /dev/null +++ b/tests/scripts/send-convenicance.json @@ -0,0 +1,86 @@ +[ + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "stream" + }, + { + "function": "fwrite", + "params": [ + "@mock-stream" + ], + "return": 26 + }, + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "stream" + }, + { + "function": "fwrite", + "params": [ + "@mock-stream" + ], + "return": 6 + }, + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "stream" + }, + { + "function": "fwrite", + "params": [ + "@mock-stream" + ], + "return": 6 + }, + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "stream" + }, + { + "function": "stream_socket_get_name", + "params": [ + "@mock-stream" + ], + "return": "127.0.0.1:12345" + }, + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "stream" + }, + { + "function": "stream_socket_get_name", + "params": [ + "@mock-stream" + ], + "return": "127.0.0.1:8000" + }, + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "stream" + }, + { + "function": "stream_socket_get_name", + "params": [ + "@mock-stream" + ], + "return": "127.0.0.1:12345" + } +] \ No newline at end of file diff --git a/tests/scripts/server.accept-destruct.json b/tests/scripts/server.accept-destruct.json index 8262346..970286b 100644 --- a/tests/scripts/server.accept-destruct.json +++ b/tests/scripts/server.accept-destruct.json @@ -6,6 +6,13 @@ ], "return": "@mock-stream" }, + { + "function": "stream_socket_get_name", + "params": [ + "@mock-stream" + ], + "return": "127.0.0.1:12345" + }, { "function": "stream_get_line", "params": [ diff --git a/tests/scripts/server.accept-error-connect.json b/tests/scripts/server.accept-error-connect.json new file mode 100644 index 0000000..f0fef27 --- /dev/null +++ b/tests/scripts/server.accept-error-connect.json @@ -0,0 +1,18 @@ +[ + { + "function": "stream_socket_accept", + "params": [], + "error": { + "msg": "A PHP error", + "type": 512 + }, + "return": false + }, + { + "function": "fclose", + "params": [ + false + ], + "return": true + } +] \ No newline at end of file diff --git a/tests/scripts/server.accept-failed-connect.json b/tests/scripts/server.accept-failed-connect.json index 36ba857..24692db 100644 --- a/tests/scripts/server.accept-failed-connect.json +++ b/tests/scripts/server.accept-failed-connect.json @@ -3,5 +3,12 @@ "function": "stream_socket_accept", "params": [], "return": false + }, + { + "function": "fclose", + "params": [ + false + ], + "return": true } ] \ No newline at end of file diff --git a/tests/scripts/server.accept-failed-http.json b/tests/scripts/server.accept-failed-http.json index 853c0e7..ab66db4 100644 --- a/tests/scripts/server.accept-failed-http.json +++ b/tests/scripts/server.accept-failed-http.json @@ -6,6 +6,13 @@ ], "return": "@mock-stream" }, + { + "function": "stream_socket_get_name", + "params": [ + "@mock-stream" + ], + "return": "127.0.0.1:12345" + }, { "function": "stream_get_line", "params": [ diff --git a/tests/scripts/server.accept-failed-ws-key.json b/tests/scripts/server.accept-failed-ws-key.json index 639b029..06bf4e6 100644 --- a/tests/scripts/server.accept-failed-ws-key.json +++ b/tests/scripts/server.accept-failed-ws-key.json @@ -6,6 +6,13 @@ ], "return": "@mock-stream" }, + { + "function": "stream_socket_get_name", + "params": [ + "@mock-stream" + ], + "return": "127.0.0.1:12345" + }, { "function": "stream_get_line", "params": [ diff --git a/tests/scripts/server.accept-timeout.json b/tests/scripts/server.accept-timeout.json index e6235a9..17a5660 100644 --- a/tests/scripts/server.accept-timeout.json +++ b/tests/scripts/server.accept-timeout.json @@ -15,6 +15,13 @@ ], "return": true }, + { + "function": "stream_socket_get_name", + "params": [ + "@mock-stream" + ], + "return": "127.0.0.1:12345" + }, { "function": "stream_get_line", "params": [ diff --git a/tests/scripts/server.accept.json b/tests/scripts/server.accept.json index 7785de1..a1463dc 100644 --- a/tests/scripts/server.accept.json +++ b/tests/scripts/server.accept.json @@ -6,6 +6,13 @@ ], "return": "@mock-stream" }, + { + "function": "stream_socket_get_name", + "params": [ + "@mock-stream" + ], + "return": "127.0.0.1:12345" + }, { "function": "stream_get_line", "params": [ diff --git a/tests/scripts/server.construct-error-socket-server.json b/tests/scripts/server.construct-error-socket-server.json new file mode 100644 index 0000000..3f4909f --- /dev/null +++ b/tests/scripts/server.construct-error-socket-server.json @@ -0,0 +1,28 @@ +[ + { + "function": "stream_socket_server", + "params": [ + "tcp://0.0.0.0:9999", + null, + null + ], + "error": { + "msg": "A PHP error", + "type": 512 + }, + "return": false + }, + { + "function": "stream_socket_server", + "params": [ + "tcp://0.0.0.0:10000", + null, + null + ], + "error": { + "msg": "A PHP error", + "type": 512 + }, + "return": false + } +] \ No newline at end of file