From a89eb9e82aea198245a659f01458678bc9a461b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Thu, 19 Nov 2020 14:35:52 +0100 Subject: [PATCH 01/19] Receiver rewrite --- docs/Changelog.md | 9 +++++ docs/Client.md | 16 +++++++-- docs/Server.md | 15 +++++++-- lib/Base.php | 80 ++++++++++++++++++++++++++++++++++---------- tests/ClientTest.php | 14 +++----- tests/ServerTest.php | 6 ++-- 6 files changed, 107 insertions(+), 33 deletions(-) diff --git a/docs/Changelog.md b/docs/Changelog.md index b610ae7..e3f5e4b 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -2,6 +2,15 @@ # Websocket: Changelog +## `v1.5` + + > PHP version `^7.2` + +### `1.5.0` + + * Opcode filter for receive() method (@sirn-se) + * Fix for unordered framgemented messages (@sirn-se) + ## `v1.4` > PHP version `^7.1` diff --git a/docs/Client.md b/docs/Client.md index 134edec..bddcd54 100644 --- a/docs/Client.md +++ b/docs/Client.md @@ -14,10 +14,10 @@ WebSocket\Client { public __destruct() public send(mixed $payload, string $opcode = 'text', bool $masked = true) : void - public receive() : mixed + public receive(array $options = ['filter' => ['text', 'binary']]) : ?string public close(int $status = 1000, mixed $message = 'ttfn') : mixed - public getLastOpcode() : string + public getLastOpcode(bool $frame = false) : string public getCloseStatus() : int public isConnected() : bool public setTimeout(int $seconds) : void @@ -60,6 +60,18 @@ 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->receive(); // return 'text' and 'binary' messages +$client->receive(['filter' => ['text']]); // only return 'text' messages +$client->receive(['filter' => ['text', 'binary', 'ping', 'pong', 'close']]); // return all messages +``` + + ## Constructor options The `$options` parameter in constructor accepts an associative array of options. diff --git a/docs/Server.md b/docs/Server.md index 3adba38..3de7197 100644 --- a/docs/Server.md +++ b/docs/Server.md @@ -18,7 +18,7 @@ WebSocket\Server { public accept() : bool public send(mixed $payload, string $opcode = 'text', bool $masked = true) : void - public receive() : mixed + public receive(array $options = ['filter' => ['text', 'binary']]) : ?string public close(int $status = 1000, mixed $message = 'ttfn') : mixed public getPort() : int @@ -26,7 +26,7 @@ WebSocket\Server { public getRequest() : array public getHeader(string $header_name) : string|null - public getLastOpcode() : string + public getLastOpcode(bool $frame = false) : string public getCloseStatus() : int public isConnected() : bool public setTimeout(int $seconds) : void @@ -70,6 +70,17 @@ 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 +$client->receive(); // return 'text' and 'binary' messages +$client->receive(['filter' => ['text']]); // only return 'text' messages +$client->receive(['filter' => ['text', 'binary', 'ping', 'pong', 'close']]); // return all messages +``` + ## Constructor options The `$options` parameter in constructor accepts an associative array of options. diff --git a/lib/Base.php b/lib/Base.php index 05058c5..4c5669d 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -19,8 +19,10 @@ class Base implements LoggerAwareInterface protected $options = []; protected $is_closing = false; protected $last_opcode = null; + protected $last_frame_opcode = null; protected $close_status = null; protected $logger; + private $read_buffer; protected static $opcodes = [ 'continuation' => 0, @@ -31,9 +33,9 @@ class Base implements LoggerAwareInterface 'pong' => 10, ]; - public function getLastOpcode(): ?string + public function getLastOpcode(bool $frame = false): ?string { - return $this->last_opcode; + return $frame ? $this->last_frame_opcode : $this->last_opcode; } public function getCloseStatus(): ?int @@ -155,20 +157,69 @@ protected function sendFragment($final, $payload, $opcode, $masked): void $this->write($frame); } - public function receive(): string + public function receive(array $options = []): ?string { + $options = array_merge(['filter' => ['text', 'binary']], $options); + if (!$this->isConnected()) { $this->connect(); } - $payload = ''; do { $response = $this->receiveFragment(); - $payload .= $response[0]; - } while (!$response[1]); + list ($payload, $final, $opcode) = $response; + $this->logger->debug("Read '{opcode}' frame", [ + 'opcode' => $opcode, + 'final' => $final, + 'content-length' => strlen($payload), + ]); + + // Continuation and factual opcode + $continuation = ($opcode == 'continuation'); + $payload_opcode = $continuation ? $this->read_buffer['opcode'] : $opcode; + $this->last_frame_opcode = $payload_opcode; + + // Filter frames + if (!in_array($payload_opcode, $options['filter'])) { + if ($payload_opcode == 'close') { + return null; // Always abort receive on close + } + continue; // Continue reading + } - $this->logger->info("Received '{$this->last_opcode}' message"); - return $payload; + // 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']++; + } + + // Not the final frame, continue + if (!$final) { + continue; // Continue reading + } + + // 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; + return $payload; + } while ($response); } protected function receiveFragment(): array @@ -233,18 +284,13 @@ protected function receiveFragment(): array 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') { @@ -271,10 +317,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]; } /** diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 4b6f48f..8f923cb 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,12 @@ 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->assertEquals('close', $client->getLastOpcode(true)); $this->assertTrue(MockSocket::isEmpty()); } @@ -213,7 +208,8 @@ 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->assertEquals('close', $client->getLastOpcode(true)); $this->assertTrue(MockSocket::isEmpty()); MockSocket::initialize('client.reconnect', $this); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 03fad0a..a49b9b9 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,7 @@ public function testServerMasked(): void $server->close(); $this->assertFalse($server->isConnected()); $this->assertEquals(1000, $server->getCloseStatus()); - $this->assertEquals('close', $server->getLastOpcode()); + $this->assertEquals('close', $server->getLastOpcode(true)); $this->assertTrue(MockSocket::isEmpty()); $server->close(); // Already closed @@ -192,7 +191,8 @@ 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()); + $this->assertEquals('close', $server->getLastOpcode(true)); } public function testSetTimeout(): void From 1e6ede72d66299faa031502b56d4b6de2cac385e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Fri, 20 Nov 2020 11:14:20 +0100 Subject: [PATCH 02/19] Global option --- docs/Client.md | 9 +- docs/Server.md | 9 +- lib/Base.php | 47 ++++----- lib/Client.php | 7 +- lib/Server.php | 5 +- tests/ClientTest.php | 25 +++++ tests/ServerTest.php | 24 +++++ tests/scripts/receive-fragmentation.json | 126 +++++++++++++++++++++++ 8 files changed, 215 insertions(+), 37 deletions(-) create mode 100644 tests/scripts/receive-fragmentation.json diff --git a/docs/Client.md b/docs/Client.md index bddcd54..cf934f5 100644 --- a/docs/Client.md +++ b/docs/Client.md @@ -66,9 +66,11 @@ 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->receive(); // return 'text' and 'binary' messages -$client->receive(['filter' => ['text']]); // only return 'text' messages -$client->receive(['filter' => ['text', 'binary', 'ping', 'pong', 'close']]); // return all messages +$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 ``` @@ -76,6 +78,7 @@ $client->receive(['filter' => ['text', 'binary', 'ping', 'pong', 'close']]); // The `$options` parameter in constructor accepts an associative array of options. +* `filter` - Array of opcodes to return on receive, default `['text', 'binary']` * `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). diff --git a/docs/Server.md b/docs/Server.md index 3de7197..bde00b2 100644 --- a/docs/Server.md +++ b/docs/Server.md @@ -76,15 +76,18 @@ 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->receive(); // return 'text' and 'binary' messages -$client->receive(['filter' => ['text']]); // only return 'text' messages -$client->receive(['filter' => ['text', 'binary', 'ping', 'pong', 'close']]); // return all messages +$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 ``` ## Constructor options The `$options` parameter in constructor accepts an associative array of options. +* `filter` - Array of opcodes to return on receive, default `['text', 'binary']` * `timeout` - Time out in seconds. Default 5 seconds. * `port` - The server port to listen to. Default 8000. * `fragment_size` - Maximum payload size. Default 4096 chars. diff --git a/lib/Base.php b/lib/Base.php index 4c5669d..6ccd80d 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -157,10 +157,9 @@ protected function sendFragment($final, $payload, $opcode, $masked): void $this->write($frame); } - public function receive(array $options = []): ?string + public function receive(): ?string { - $options = array_merge(['filter' => ['text', 'binary']], $options); - + $filter = $this->options['filter']; if (!$this->isConnected()) { $this->connect(); } @@ -180,10 +179,11 @@ public function receive(array $options = []): ?string $this->last_frame_opcode = $payload_opcode; // Filter frames - if (!in_array($payload_opcode, $options['filter'])) { + if (!in_array($payload_opcode, $filter)) { if ($payload_opcode == 'close') { return null; // Always abort receive on close } + $final = false; continue; // Continue reading } @@ -198,28 +198,23 @@ public function receive(array $options = []): ?string $this->read_buffer['payload'] .= $payload; $this->read_buffer['frames']++; } - - // Not the final frame, continue - if (!$final) { - continue; // Continue reading - } - - // 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; - return $payload; - } while ($response); + } 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; + return $payload; } protected function receiveFragment(): array diff --git a/lib/Client.php b/lib/Client.php index 0bb5dcb..a3badf3 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -13,13 +13,14 @@ 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, + 'timeout' => 5, ]; protected $socket_uri; diff --git a/lib/Server.php b/lib/Server.php index 1ff1940..e640a4c 100644 --- a/lib/Server.php +++ b/lib/Server.php @@ -13,10 +13,11 @@ class Server extends Base { // Default options protected static $default_options = [ - 'timeout' => null, + 'filter' => ['text', 'binary'], 'fragment_size' => 4096, - 'port' => 8000, 'logger' => null, + 'port' => 8000, + 'timeout' => null, ]; protected $addr; diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 8f923cb..3a2a5ee 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -354,4 +354,29 @@ 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()); + } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index a49b9b9..bf0e95c 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -354,4 +354,28 @@ 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()); + } } 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 From 13fb0dcf0c7998915e733c90fa8ad201554fdf21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Fri, 20 Nov 2020 11:20:49 +0100 Subject: [PATCH 03/19] Global option --- docs/Client.md | 2 +- docs/Server.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Client.md b/docs/Client.md index cf934f5..7024185 100644 --- a/docs/Client.md +++ b/docs/Client.md @@ -14,7 +14,7 @@ WebSocket\Client { public __destruct() public send(mixed $payload, string $opcode = 'text', bool $masked = true) : void - public receive(array $options = ['filter' => ['text', 'binary']]) : ?string + public receive() : ?string public close(int $status = 1000, mixed $message = 'ttfn') : mixed public getLastOpcode(bool $frame = false) : string diff --git a/docs/Server.md b/docs/Server.md index bde00b2..eedd443 100644 --- a/docs/Server.md +++ b/docs/Server.md @@ -18,7 +18,7 @@ WebSocket\Server { public accept() : bool public send(mixed $payload, string $opcode = 'text', bool $masked = true) : void - public receive(array $options = ['filter' => ['text', 'binary']]) : ?string + public receive() : ?string public close(int $status = 1000, mixed $message = 'ttfn') : mixed public getPort() : int From 1449f79fc2b224fe71cf4d06055f41f03ea9b462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Tue, 24 Nov 2020 21:07:05 +0100 Subject: [PATCH 04/19] Exception meta-data --- lib/Base.php | 10 ++++------ lib/ConnectionException.php | 15 +++++++++++++++ lib/Server.php | 2 +- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/lib/Base.php b/lib/Base.php index 5af7bf6..476f4fb 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -379,17 +379,15 @@ protected function read($length): string protected function throwException($message, $code = 0): void { $meta = stream_get_meta_data($this->socket); - $json_meta = json_encode($meta); 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); + $this->logger->error($message, $meta); + throw new ConnectionException($message, $code, $meta); } /** 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/Server.php b/lib/Server.php index e640a4c..236c66b 100644 --- a/lib/Server.php +++ b/lib/Server.php @@ -46,7 +46,7 @@ public function __construct(array $options = []) if (!$this->listening) { $error = "Could not open listening socket: {$errstr} ({$errno})"; $this->logger->error($error); - throw new ConnectionException($error, $errno); + throw new ConnectionException($error, (int)$errno); } $this->logger->info("Server listening to port {$this->port}"); From f226cb8272a88145487385e66c2b82ffca85f8f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Tue, 24 Nov 2020 21:47:11 +0100 Subject: [PATCH 05/19] Frame reading --- lib/Base.php | 38 ++++++++++++-------------------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/lib/Base.php b/lib/Base.php index 476f4fb..b4f6a7a 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -219,19 +219,15 @@ public function receive(): ?string 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}"; @@ -240,20 +236,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. @@ -292,7 +290,7 @@ protected function receiveFragment(): array // Get the close 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- @@ -389,16 +387,4 @@ protected function throwException($message, $code = 0): void $this->logger->error($message, $meta); throw new ConnectionException($message, $code, $meta); } - - /** - * 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; - } } From 60595ebb1761af7d3be24624b6bb1d530ffd7af4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Wed, 25 Nov 2020 22:05:14 +0100 Subject: [PATCH 06/19] Tests, write rewrite --- lib/Base.php | 40 +++++++++++-------------------- tests/ExceptionTest.php | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 27 deletions(-) create mode 100644 tests/ExceptionTest.php diff --git a/lib/Base.php b/lib/Base.php index b4f6a7a..afd5d7c 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -105,38 +105,24 @@ public function send($payload, $opcode = 'text', $masked = true): void protected function sendFragment($final, $payload, $opcode, $masked): void { - // Binary string for header. - $frame_head_binstr = ''; - - // Write FIN, final fragment bit. - $frame_head_binstr .= (bool) $final ? '1' : '0'; - - // RSV 1, 2, & 3 false and unused. - $frame_head_binstr .= '000'; + $data = ''; - // Opcode rest of the byte. - $frame_head_binstr .= sprintf('%04b', self::$opcodes[$opcode]); + $byte_1 = $final ? 0b10000000 : 0b00000000; // Final fragment marker. + $byte_1 |= self::$opcodes[$opcode]; // Set opcode. + $data .= pack('C', $byte_1); - // Use masking? - $frame_head_binstr .= $masked ? '1' : '0'; + $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 @@ -146,15 +132,15 @@ 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); } public function receive(): ?string 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()); + } +} From b379c387554007bd6fc7f99265e3826c95342ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 28 Nov 2020 13:25:50 +0100 Subject: [PATCH 07/19] Convenience senders --- README.md | 4 +-- docs/Client.md | 31 ++++++++++++++++---- docs/Server.md | 28 ++++++++++++++++-- examples/echoserver.php | 6 ++-- examples/random_client.php | 8 ++--- examples/random_server.php | 8 ++--- lib/Base.php | 36 +++++++++++++++++++++++ tests/ClientTest.php | 12 ++++++++ tests/ServerTest.php | 14 +++++++++ tests/scripts/send-convenicance.json | 44 ++++++++++++++++++++++++++++ 10 files changed, 170 insertions(+), 21 deletions(-) create mode 100644 tests/scripts/send-convenicance.json diff --git a/README.md b/README.md index 3863641..9694d06 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,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 +50,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(); ``` diff --git a/docs/Client.md b/docs/Client.md index f09e769..89c49c5 100644 --- a/docs/Client.md +++ b/docs/Client.md @@ -13,6 +13,10 @@ WebSocket\Client { public __construct(string $uri, array $options = []) public __destruct() + 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() : ?string public close(int $status = 1000, mixed $message = 'ttfn') : mixed @@ -35,7 +39,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(); ``` @@ -67,24 +71,41 @@ 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->receive(); // Only return 'text' messages $client = new WebSocket\Client("ws://echo.websocket.org/", ['filter' => ['text', 'binary', 'ping', 'pong', 'close']]); -$client->receive(); // return all messages +$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. +* `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']` -* `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). * `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. +* `timeout` - Time out in seconds. Default 5 seconds. ```php $context = stream_context_create(); diff --git a/docs/Server.md b/docs/Server.md index 6e054d7..7ef7816 100644 --- a/docs/Server.md +++ b/docs/Server.md @@ -17,6 +17,10 @@ WebSocket\Server { public __destruct() 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() : ?string public close(int $status = 1000, mixed $message = 'ttfn') : mixed @@ -46,7 +50,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(); ``` @@ -83,15 +87,33 @@ $server = new WebSocket\Server(['filter' => ['text', 'binary', 'ping', 'pong', ' $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. * `filter` - Array of opcodes to return on receive, default `['text', 'binary']` -* `timeout` - Time out in seconds. Default 5 seconds. -* `port` - The server port to listen to. Default 8000. * `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. +* `timeout` - Time out in seconds. Default 5 seconds. ```php $server = new WebSocket\Server([ diff --git a/examples/echoserver.php b/examples/echoserver.php index 9112436..d8d5470 100644 --- a/examples/echoserver.php +++ b/examples/echoserver.php @@ -50,14 +50,14 @@ echo "> Close status: {$server->getCloseStatus()}\n"; exit; case 'headers': - $server->send(implode("\r\n", $server->getRequest())); + $server-text(implode("\r\n", $server->getRequest())); break; case 'ping': - $server->send($message, 'ping'); + $server-ping($message); break; case 'auth': $auth = $server->getHeader('Authorization'); - $server->send("{$auth} - {$message}", $opcode); + $server->text("{$auth} - {$message}"); break; default: $server->send($message, $opcode); 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..4d945c7 100644 --- a/examples/random_server.php +++ b/examples/random_server.php @@ -59,11 +59,11 @@ switch (rand(1, 10)) { case 1: echo "> Sending text\n"; - $server->send("Text message {$randStr()}", 'text'); + $server->text("Text message {$randStr()}"); break; case 2: echo "> Sending binary\n"; - $server->send("Binary message {$randStr()}", 'binary'); + $server->binary("Binary message {$randStr()}"); break; case 3: echo "> Sending close\n"; @@ -71,11 +71,11 @@ break; case 4: echo "> Sending ping\n"; - $server->send("Ping message {$randStr(8)}", 'ping'); + $server->ping("Ping message {$randStr(8)}"); break; case 5: echo "> Sending pong\n"; - $server->send("Pong message {$randStr(8)}", 'pong'); + $server->pong("Pong message {$randStr(8)}"); break; default: echo "> Receiving\n"; diff --git a/lib/Base.php b/lib/Base.php index dbc6bd9..211f7e4 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -103,6 +103,42 @@ public function send($payload, $opcode = 'text', $masked = true): void $this->logger->info("Sent '{$opcode}' message"); } + /** + * Convenience method to send text message + * @param string $payload Content as string + */ + public function text(string $payload): void + { + $this->send($payload); + } + + /** + * Convenience method to send binary message + * @param string $payload Content as binary string + */ + public function binary(string $payload): void + { + $this->send($payload, 'binary'); + } + + /** + * Convenience method to send ping + * @param string $payload Optional text as string + */ + public function ping(string $payload = ''): void + { + $this->send($payload, 'ping'); + } + + /** + * Convenience method to send unsolicited pong + * @param string $payload Optional text as string + */ + public function pong(string $payload = ''): void + { + $this->send($payload, 'pong'); + } + protected function sendFragment($final, $payload, $opcode, $masked): void { $data = ''; diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 3a2a5ee..f3d0bb6 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -379,4 +379,16 @@ public function testFrameFragmentation(): void $this->assertEquals(17260, $client->getCloseStatus()); $this->assertEquals('close', $client->getLastOpcode()); } + + public function testConvenicanceMethods(): void + { + MockSocket::initialize('client.connect', $this); + $client = new Client('ws://localhost:8000/my/mock/path'); + $client->text('Connect'); + MockSocket::initialize('send-convenicance', $this); + $client->binary(base64_encode('Binary content')); + $client->ping(); + $client->pong(); + $this->assertTrue(MockSocket::isEmpty()); + } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index bf0e95c..47a761f 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -378,4 +378,18 @@ public function testFrameFragmentation(): void $this->assertEquals(17260, $server->getCloseStatus()); $this->assertEquals('close', $server->getLastOpcode()); } + + public function testConvenicanceMethods(): void + { + MockSocket::initialize('server.construct', $this); + $server = new 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->assertTrue(MockSocket::isEmpty()); + } } diff --git a/tests/scripts/send-convenicance.json b/tests/scripts/send-convenicance.json new file mode 100644 index 0000000..d3a0fc8 --- /dev/null +++ b/tests/scripts/send-convenicance.json @@ -0,0 +1,44 @@ +[ + { + "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 + } +] \ No newline at end of file From 61f098a853de745ec21054ef024a405e2b7e19a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 28 Nov 2020 13:27:49 +0100 Subject: [PATCH 08/19] Convenience senders --- docs/Changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/Changelog.md b/docs/Changelog.md index da8ab16..87bc97f 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -8,8 +8,10 @@ ### `1.5.0` + * Convenience send methods; text(), binary(), ping(), pong() (@sirn-se) * Opcode filter for receive() method (@sirn-se) * Fix for unordered framgemented messages (@sirn-se) + * Various code re-write (@sirn-se) ## `v1.4` From dfa38e90ac42a6af4cb3233e31cf17cfcd5a57e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 28 Nov 2020 13:31:10 +0100 Subject: [PATCH 09/19] Convenience senders --- examples/echoserver.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/echoserver.php b/examples/echoserver.php index d8d5470..8d0775b 100644 --- a/examples/echoserver.php +++ b/examples/echoserver.php @@ -50,10 +50,10 @@ echo "> Close status: {$server->getCloseStatus()}\n"; exit; case 'headers': - $server-text(implode("\r\n", $server->getRequest())); + $server->text(implode("\r\n", $server->getRequest())); break; case 'ping': - $server-ping($message); + $server->ping($message); break; case 'auth': $auth = $server->getHeader('Authorization'); From a057277bf0d865c4c62387073d8027b751e6c21a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sun, 29 Nov 2020 13:51:46 +0100 Subject: [PATCH 10/19] Minor changes --- examples/echoserver.php | 59 +++++++++++-------- examples/random_server.php | 58 +++++++++--------- examples/send.php | 9 ++- lib/Base.php | 29 ++++++--- lib/Server.php | 13 ++-- tests/mock/EchoLog.php | 3 +- tests/mock/mock-socket.php | 5 ++ tests/scripts/server.accept-destruct.json | 7 +++ tests/scripts/server.accept-failed-http.json | 7 +++ .../scripts/server.accept-failed-ws-key.json | 7 +++ tests/scripts/server.accept-timeout.json | 7 +++ tests/scripts/server.accept.json | 7 +++ 12 files changed, 138 insertions(+), 73 deletions(-) diff --git a/examples/echoserver.php b/examples/echoserver.php index 8d0775b..fb9aefe 100644 --- a/examples/echoserver.php +++ b/examples/echoserver.php @@ -36,34 +36,41 @@ 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(); - 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->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->send($message, $opcode); + while ($server->accept()) { + echo "> Accepted on port {$server->getPort()}\n"; + while (true) { + $message = $server->receive(); + $opcode = $server->getLastOpcode(); + if ($opcode == 'close') { + echo "> Closing connection\n"; + continue 2; + } + 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->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->send($message, $opcode); + } } } - } 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_server.php b/examples/random_server.php index 4d945c7..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->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"; + 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..6a12631 100644 --- a/examples/send.php +++ b/examples/send.php @@ -36,9 +36,12 @@ // 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(); + 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 211f7e4..f169960 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -100,7 +100,11 @@ 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), + ]); } /** @@ -175,8 +179,12 @@ protected function sendFragment($final, $payload, $opcode, $masked): void for ($i = 0; $i < $payload_length; $i++) { $data .= ($masked === true) ? $payload[$i] ^ $mask[$i % 4] : $payload[$i]; } - $this->write($data); + $this->logger->debug("Sent '{$opcode}' frame", [ + 'opcode' => $opcode, + 'final' => $final, + 'content-length' => strlen($payload), + ]); } public function receive(): ?string @@ -189,11 +197,6 @@ public function receive(): ?string do { $response = $this->receiveFragment(); list ($payload, $final, $opcode) = $response; - $this->logger->debug("Read '{opcode}' frame", [ - 'opcode' => $opcode, - 'final' => $final, - 'content-length' => strlen($payload), - ]); // Continuation and factual opcode $continuation = ($opcode == 'continuation'); @@ -295,6 +298,12 @@ 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'."); @@ -309,13 +318,15 @@ protected function receiveFragment(): array } if ($opcode === 'close') { + $status_bin = ''; + $status = ''; // Get the close status. if ($payload_length > 0) { $status_bin = $payload[0] . $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); } @@ -392,6 +403,8 @@ 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; } diff --git a/lib/Server.php b/lib/Server.php index 236c66b..e9500c1 100644 --- a/lib/Server.php +++ b/lib/Server.php @@ -97,22 +97,21 @@ 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); + $this->throwException('Server failed to connect.'); } } 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->throwException('Server failed to connect.'); } stream_set_timeout($this->socket, $this->options['timeout']); } + $this->logger->info("Server connected to port {port}", [ + 'port' => $this->port, + 'pier' => stream_socket_get_name($this->socket, true), + ]); $this->performHandshake(); - $this->logger->info("Server connected to port {$this->port}"); } protected function performHandshake(): void 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/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/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-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": [ From b67fc192e26039b4214c1ba1503162f083bab23d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Mon, 30 Nov 2020 16:38:29 +0100 Subject: [PATCH 11/19] Get name/pier, tostring --- docs/Client.md | 3 ++ docs/Server.md | 3 ++ lib/Base.php | 31 ++++++++++++++++++++ tests/ClientTest.php | 7 ++++- tests/ServerTest.php | 6 ++++ tests/mock/MockSocket.php | 2 +- tests/scripts/send-convenicance.json | 42 ++++++++++++++++++++++++++++ 7 files changed, 92 insertions(+), 2 deletions(-) diff --git a/docs/Client.md b/docs/Client.md index 89c49c5..d9f434f 100644 --- a/docs/Client.md +++ b/docs/Client.md @@ -12,6 +12,7 @@ WebSocket\Client { public __construct(string $uri, array $options = []) public __destruct() + public __toString() : string public text(string $payload) : void public binary(string $payload) : void @@ -21,6 +22,8 @@ WebSocket\Client { public receive() : ?string public close(int $status = 1000, mixed $message = 'ttfn') : mixed + public getName() : string|null + public getRemoteName() : string|null public getLastOpcode(bool $frame = false) : string public getCloseStatus() : int public isConnected() : bool diff --git a/docs/Server.md b/docs/Server.md index 7ef7816..fc591ba 100644 --- a/docs/Server.md +++ b/docs/Server.md @@ -15,6 +15,7 @@ WebSocket\Server { public __construct(array $options = []) public __destruct() + public __toString() : string public accept() : bool public text(string $payload) : void @@ -30,6 +31,8 @@ WebSocket\Server { public getRequest() : array public getHeader(string $header_name) : string|null + public getName() : string|null + public getRemoteName() : string|null public getLastOpcode(bool $frame = false) : string public getCloseStatus() : int public isConnected() : bool diff --git a/lib/Base.php b/lib/Base.php index f169960..2465b29 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -143,6 +143,37 @@ public function pong(string $payload = ''): void $this->send($payload, 'pong'); } + /** + * 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 getRemote(bool $pier = false): ?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' + ); + } + protected function sendFragment($final, $payload, $opcode, $masked): void { $data = ''; diff --git a/tests/ClientTest.php b/tests/ClientTest.php index f3d0bb6..794e8dd 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -384,11 +384,16 @@ 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->getRemote()); + $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->assertTrue(MockSocket::isEmpty()); + $this->assertEquals('127.0.0.1:12345', $client->getName()); + $this->assertEquals('127.0.0.1:8000', $client->getRemote()); + $this->assertEquals('WebSocket\Client(127.0.0.1:12345)', "{$client}"); } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 47a761f..430e5fd 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -383,6 +383,9 @@ public function testConvenicanceMethods(): void { MockSocket::initialize('server.construct', $this); $server = new Server(); + $this->assertNull($server->getName()); + $this->assertNull($server->getRemote()); + $this->assertEquals('WebSocket\Server(closed)', "{$server}"); MockSocket::initialize('server.accept', $this); $server->accept(); $server->text('Connect'); @@ -390,6 +393,9 @@ public function testConvenicanceMethods(): void $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->getRemote()); + $this->assertEquals('WebSocket\Server(127.0.0.1:12345)', "{$server}"); $this->assertTrue(MockSocket::isEmpty()); } } diff --git a/tests/mock/MockSocket.php b/tests/mock/MockSocket.php index 279b3ea..6a6f515 100644 --- a/tests/mock/MockSocket.php +++ b/tests/mock/MockSocket.php @@ -20,7 +20,7 @@ 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])); } diff --git a/tests/scripts/send-convenicance.json b/tests/scripts/send-convenicance.json index d3a0fc8..194cb5a 100644 --- a/tests/scripts/send-convenicance.json +++ b/tests/scripts/send-convenicance.json @@ -40,5 +40,47 @@ "@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 From da7f6ca45c32832ce0a150a39e2115abf7b6f486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Mon, 30 Nov 2020 21:04:25 +0100 Subject: [PATCH 12/19] Messages --- docs/Changelog.md | 2 +- docs/Client.md | 3 ++- docs/Contributing.md | 2 +- docs/Examples.md | 2 +- docs/Message.md | 56 ++++++++++++++++++++++++++++++++++++++ docs/Server.md | 3 ++- lib/Base.php | 8 ++++-- lib/Client.php | 1 + lib/Message/Binary.php | 8 ++++++ lib/Message/Close.php | 8 ++++++ lib/Message/Factory.php | 25 +++++++++++++++++ lib/Message/Message.php | 44 ++++++++++++++++++++++++++++++ lib/Message/Ping.php | 8 ++++++ lib/Message/Pong.php | 8 ++++++ lib/Message/Text.php | 8 ++++++ lib/Server.php | 1 + tests/ClientTest.php | 28 +++++++++++++++++++ tests/MessageTest.php | 60 +++++++++++++++++++++++++++++++++++++++++ tests/ServerTest.php | 27 +++++++++++++++++++ 19 files changed, 295 insertions(+), 7 deletions(-) create mode 100644 docs/Message.md create mode 100644 lib/Message/Binary.php create mode 100644 lib/Message/Close.php create mode 100644 lib/Message/Factory.php create mode 100644 lib/Message/Message.php create mode 100644 lib/Message/Ping.php create mode 100644 lib/Message/Pong.php create mode 100644 lib/Message/Text.php create mode 100644 tests/MessageTest.php diff --git a/docs/Changelog.md b/docs/Changelog.md index 87bc97f..8dbc804 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -1,4 +1,4 @@ -[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 diff --git a/docs/Client.md b/docs/Client.md index 89c49c5..1d08d65 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 @@ -105,6 +105,7 @@ The `$options` parameter in constructor accepts an associative array of options. * `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 instance on receive, default false * `timeout` - Time out in seconds. Default 5 seconds. ```php diff --git a/docs/Contributing.md b/docs/Contributing.md index 7a9237d..47006dd 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 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..65dc781 --- /dev/null +++ b/docs/Message.md @@ -0,0 +1,56 @@ +[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 class synopsis + +```php +WebSocket\Message\Message { + + public __construct(string $payload = '') + public __toString() : string + + public getOpcode() : string + public getLength() : int + public getContent() : string + public setContent(string $payload = '') : void + public hasContent() : bool +} +``` + +## Factory class synopsis + +```php +WebSocket\Message\Message { + + public create(string $opcode, string $payload = '') : Message +} +``` + +## Example + +```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 +$client->close(); +``` \ No newline at end of file diff --git a/docs/Server.md b/docs/Server.md index 7ef7816..0d8f662 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 @@ -113,6 +113,7 @@ The `$options` parameter in constructor accepts an associative array of options. * `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 instance on receive, default false * `timeout` - Time out in seconds. Default 5 seconds. ```php diff --git a/lib/Base.php b/lib/Base.php index f169960..e272585 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -12,6 +12,7 @@ use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use WebSocket\Message\Factory; class Base implements LoggerAwareInterface { @@ -187,7 +188,7 @@ protected function sendFragment($final, $payload, $opcode, $masked): void ]); } - public function receive(): ?string + public function receive() { $filter = $this->options['filter']; if (!$this->isConnected()) { @@ -239,7 +240,10 @@ public function receive(): ?string ]); $this->last_opcode = $payload_opcode; - return $payload; + $factory = new Factory(); + return $this->options['return_obj'] + ? $factory->create($payload_opcode, $payload) + : $payload; } protected function receiveFragment(): array diff --git a/lib/Client.php b/lib/Client.php index 0460388..cb46a14 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -20,6 +20,7 @@ class Client extends Base 'logger' => null, 'origin' => null, // @deprecated 'persistent' => false, + 'return_obj' => false, 'timeout' => 5, ]; 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; + } + + public function getOpcode(): string + { + return $this->opcode; + } + + public function getLength(): int + { + return strlen($this->payload); + } + + 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 @@ + 4096, 'logger' => null, 'port' => 8000, + 'return_obj' => false, 'timeout' => null, ]; diff --git a/tests/ClientTest.php b/tests/ClientTest.php index f3d0bb6..826f824 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -380,6 +380,34 @@ public function testFrameFragmentation(): void $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); diff --git a/tests/MessageTest.php b/tests/MessageTest.php new file mode 100644 index 0000000..a2d4d80 --- /dev/null +++ b/tests/MessageTest.php @@ -0,0 +1,60 @@ +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()); + $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 47a761f..8c03336 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -379,6 +379,33 @@ public function testFrameFragmentation(): void $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); From 2a81ee969110b059921121f9a42fda8844074473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Mon, 30 Nov 2020 21:10:10 +0100 Subject: [PATCH 13/19] Messages --- README.md | 1 + docs/Changelog.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 9694d06..6ad1925 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) diff --git a/docs/Changelog.md b/docs/Changelog.md index 8dbc804..8978b26 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -9,6 +9,7 @@ ### `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) * Fix for unordered framgemented messages (@sirn-se) * Various code re-write (@sirn-se) From 0546dd9e353d7c17450e4a1975d24a4c4fffedfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Tue, 1 Dec 2020 08:54:24 +0100 Subject: [PATCH 14/19] Messages --- docs/Message.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/Message.md b/docs/Message.md index 65dc781..bb6de2f 100644 --- a/docs/Message.md +++ b/docs/Message.md @@ -16,7 +16,7 @@ Additionally; * WebSocket\Message\Message - abstract base class for all messages above * WebSocket\Message\Factory - Factory class to create Msssage instances -## Message class synopsis +## Message abstract class synopsis ```php WebSocket\Message\Message { @@ -35,7 +35,7 @@ WebSocket\Message\Message { ## Factory class synopsis ```php -WebSocket\Message\Message { +WebSocket\Message\Factory { public create(string $opcode, string $payload = '') : Message } @@ -43,6 +43,8 @@ WebSocket\Message\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!'); @@ -53,4 +55,4 @@ echo $message->getLength(); // -> 20 echo $message->getContent(); // -> "Hello WebSocket.org!" echo $message->hasContent(); // -> true $client->close(); -``` \ No newline at end of file +``` From e40a9b78ad736d567fd3bce8cb49f5810f7733a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Fri, 11 Dec 2020 16:37:24 +0100 Subject: [PATCH 15/19] Version 1.5 --- .travis.yml | 1 - README.md | 3 ++- composer.json | 4 ++-- docs/Changelog.md | 2 +- docs/Client.md | 4 ++-- docs/Server.md | 4 ++-- examples/echoserver.php | 9 +++++++-- lib/Base.php | 41 +++++++++++++++++++++++------------------ lib/Client.php | 6 ++---- tests/ClientTest.php | 6 ++---- tests/ServerTest.php | 6 ++---- 11 files changed, 45 insertions(+), 41 deletions(-) diff --git a/.travis.yml b/.travis.yml index 37a4fce..216d280 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,6 @@ php: - 7.4 - 7.3 - 7.2 - - 7.1 before_script: - make install build diff --git a/README.md b/README.md index 6ad1925..59ff565 100644 --- a/README.md +++ b/README.md @@ -24,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`. +* For PHP `7.1` support use version `1.4`. * For PHP `^5.4` and `7.0` support use version `1.3`. ## Client diff --git a/composer.json b/composer.json index fdf7c3d..4cdad0a 100644 --- a/composer.json +++ b/composer.json @@ -23,11 +23,11 @@ } }, "require": { - "php": "^7.1", + "php": "^7.2", "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 14b582f..6b50cb4 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -11,7 +11,7 @@ * 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) - * Fix for unordered framgemented messages (@sirn-se) + * Fix for unordered framgmented messages (@sirn-se) * Various code re-write (@sirn-se) ## `v1.4` diff --git a/docs/Client.md b/docs/Client.md index 856c4cb..1e6376a 100644 --- a/docs/Client.md +++ b/docs/Client.md @@ -23,8 +23,8 @@ WebSocket\Client { public close(int $status = 1000, mixed $message = 'ttfn') : mixed public getName() : string|null - public getRemoteName() : string|null - public getLastOpcode(bool $frame = false) : string + public getPier() : string|null + public getLastOpcode() : string public getCloseStatus() : int public isConnected() : bool public setTimeout(int $seconds) : void diff --git a/docs/Server.md b/docs/Server.md index e75bc23..3e95dbe 100644 --- a/docs/Server.md +++ b/docs/Server.md @@ -32,8 +32,8 @@ WebSocket\Server { public getHeader(string $header_name) : string|null public getName() : string|null - public getRemoteName() : string|null - public getLastOpcode(bool $frame = false) : string + public getPier() : string|null + public getLastOpcode() : string public getCloseStatus() : int public isConnected() : bool public setTimeout(int $seconds) : void diff --git a/examples/echoserver.php b/examples/echoserver.php index fb9aefe..8fee25c 100644 --- a/examples/echoserver.php +++ b/examples/echoserver.php @@ -32,7 +32,12 @@ } // 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"; @@ -44,7 +49,7 @@ while (true) { $message = $server->receive(); $opcode = $server->getLastOpcode(); - if ($opcode == 'close') { + if (is_null($message)) { echo "> Closing connection\n"; continue 2; } diff --git a/lib/Base.php b/lib/Base.php index 0685600..2c84489 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -9,9 +9,7 @@ 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 @@ -20,7 +18,6 @@ class Base implements LoggerAwareInterface protected $options = []; protected $is_closing = false; protected $last_opcode = null; - protected $last_frame_opcode = null; protected $close_status = null; protected $logger; private $read_buffer; @@ -34,9 +31,9 @@ class Base implements LoggerAwareInterface 'pong' => 10, ]; - public function getLastOpcode(bool $frame = false): ?string + public function getLastOpcode(): ?string { - return $frame ? $this->last_frame_opcode : $this->last_opcode; + return $this->last_opcode; } public function getCloseStatus(): ?int @@ -51,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; @@ -60,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; @@ -76,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(); @@ -157,7 +154,7 @@ public function getName(): ?string * Get name of remote socket, or null if not connected * @return string|null */ - public function getRemote(bool $pier = false): ?string + public function getPier(): ?string { return $this->isConnected() ? stream_socket_get_name($this->socket, true) : null; } @@ -175,7 +172,12 @@ public function __toString(): string ); } - protected function sendFragment($final, $payload, $opcode, $masked): void + /** + * 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 = ''; @@ -233,7 +235,6 @@ public function receive() // Continuation and factual opcode $continuation = ($opcode == 'continuation'); $payload_opcode = $continuation ? $this->read_buffer['opcode'] : $opcode; - $this->last_frame_opcode = $payload_opcode; // Filter frames if (!in_array($payload_opcode, $filter)) { @@ -392,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; @@ -409,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); @@ -422,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) { @@ -441,11 +442,15 @@ protected function read($length): string 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'])) { $this->logger->error($message, $meta); throw new TimeoutException($message, ConnectionException::TIMED_OUT, $meta); diff --git a/lib/Client.php b/lib/Client.php index 9c3a920..1e39040 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -35,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; @@ -198,11 +198,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/tests/ClientTest.php b/tests/ClientTest.php index 8842e43..a0ffce4 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -178,7 +178,6 @@ public function testRemoteClose(): void $this->assertFalse($client->isConnected()); $this->assertEquals(17260, $client->getCloseStatus()); $this->assertNull($client->getLastOpcode()); - $this->assertEquals('close', $client->getLastOpcode(true)); $this->assertTrue(MockSocket::isEmpty()); } @@ -209,7 +208,6 @@ public function testReconnect(): void $this->assertFalse($client->isConnected()); $this->assertEquals(1000, $client->getCloseStatus()); $this->assertNull($client->getLastOpcode()); - $this->assertEquals('close', $client->getLastOpcode(true)); $this->assertTrue(MockSocket::isEmpty()); MockSocket::initialize('client.reconnect', $this); @@ -413,7 +411,7 @@ 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->getRemote()); + $this->assertNull($client->getPier()); $this->assertEquals('WebSocket\Client(closed)', "{$client}"); $client->text('Connect'); MockSocket::initialize('send-convenicance', $this); @@ -421,7 +419,7 @@ public function testConvenicanceMethods(): void $client->ping(); $client->pong(); $this->assertEquals('127.0.0.1:12345', $client->getName()); - $this->assertEquals('127.0.0.1:8000', $client->getRemote()); + $this->assertEquals('127.0.0.1:8000', $client->getPier()); $this->assertEquals('WebSocket\Client(127.0.0.1:12345)', "{$client}"); } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 921d0d9..d5362bd 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -59,7 +59,6 @@ public function testServerMasked(): void $server->close(); $this->assertFalse($server->isConnected()); $this->assertEquals(1000, $server->getCloseStatus()); - $this->assertEquals('close', $server->getLastOpcode(true)); $this->assertTrue(MockSocket::isEmpty()); $server->close(); // Already closed @@ -192,7 +191,6 @@ public function testRemoteClose(): void $this->assertFalse($server->isConnected()); $this->assertEquals(17260, $server->getCloseStatus()); $this->assertNull($server->getLastOpcode()); - $this->assertEquals('close', $server->getLastOpcode(true)); } public function testSetTimeout(): void @@ -411,7 +409,7 @@ public function testConvenicanceMethods(): void MockSocket::initialize('server.construct', $this); $server = new Server(); $this->assertNull($server->getName()); - $this->assertNull($server->getRemote()); + $this->assertNull($server->getPier()); $this->assertEquals('WebSocket\Server(closed)', "{$server}"); MockSocket::initialize('server.accept', $this); $server->accept(); @@ -421,7 +419,7 @@ public function testConvenicanceMethods(): void $server->ping(); $server->pong(); $this->assertEquals('127.0.0.1:12345', $server->getName()); - $this->assertEquals('127.0.0.1:8000', $server->getRemote()); + $this->assertEquals('127.0.0.1:8000', $server->getPier()); $this->assertEquals('WebSocket\Server(127.0.0.1:12345)', "{$server}"); $this->assertTrue(MockSocket::isEmpty()); } From 9506013dd2c8e15d880ec58f051eecfe261bf30d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Fri, 11 Dec 2020 17:31:03 +0100 Subject: [PATCH 16/19] Version 1.5 --- README.md | 2 +- docs/Changelog.md | 2 ++ examples/echoserver.php | 8 +++++++- examples/send.php | 4 +++- lib/Client.php | 19 +++++++++++++++---- lib/Server.php | 39 ++++++++++++++++++++++++++++----------- 6 files changed, 56 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 59ff565..edf3782 100644 --- a/README.md +++ b/README.md @@ -63,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/docs/Changelog.md b/docs/Changelog.md index 6b50cb4..2323d53 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -11,7 +11,9 @@ * 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) + * PHP `8.0` support (@webpatser) * Fix for unordered framgmented messages (@sirn-se) + * Improved error handling on stream calls (@sirn-se) * Various code re-write (@sirn-se) ## `v1.4` diff --git a/examples/echoserver.php b/examples/echoserver.php index 8fee25c..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 @@ -54,6 +55,11 @@ 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"; @@ -71,7 +77,7 @@ $server->text("{$auth} - {$message}"); break; default: - $server->send($message, $opcode); + $server->text($message); } } } diff --git a/examples/send.php b/examples/send.php index 6a12631..30e48e0 100644 --- a/examples/send.php +++ b/examples/send.php @@ -40,7 +40,9 @@ if (in_array($options['opcode'], ['text', 'binary'])) { $message = $client->receive(); $opcode = $client->getLastOpcode(); - echo "> Got '{$message}' [opcode: {$opcode}]\n"; + if (!is_null($message)) { + echo "> Got '{$message}' [opcode: {$opcode}]\n"; + } } $client->close(); echo "> Closing client\n"; diff --git a/lib/Client.php b/lib/Client.php index 1e39040..92a459b 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -38,6 +38,9 @@ class Client extends Base public function __construct(string $uri, array $options = []) { $this->options = array_merge(self::$default_options, $options); + if (is_null($this->options['timeout'])) { + $this->options['timeout'] = ini_get('default_socket_timeout'); + } $this->socket_uri = $uri; $this->setLogger($this->options['logger']); } @@ -103,9 +106,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'], @@ -113,8 +122,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); } diff --git a/lib/Server.php b/lib/Server.php index 6001ccb..ae9325f 100644 --- a/lib/Server.php +++ b/lib/Server.php @@ -40,12 +40,20 @@ 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, (int)$errno); } @@ -95,16 +103,25 @@ public function accept(): bool protected function connect(): void { - if (empty($this->options['timeout'])) { - $this->socket = @stream_socket_accept($this->listening); - if (!$this->socket) { - $this->throwException('Server failed to connect.'); - } + + $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) { - $this->throwException('Server failed to connect.'); - } + $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']); } From f0d226108ad56d86c6b501b777e79d2db5c94ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 12 Dec 2020 11:15:22 +0100 Subject: [PATCH 17/19] Version 1.5 --- README.md | 2 +- docs/Client.md | 11 +++++++---- docs/Server.md | 7 +++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index edf3782..18311ba 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Preferred way to install is with [Composer](https://getcomposer.org/). composer require textalk/websocket ``` -* Current version support PHP versions `^7.2`. +* 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`. diff --git a/docs/Client.md b/docs/Client.md index 1e6376a..9898383 100644 --- a/docs/Client.md +++ b/docs/Client.md @@ -108,7 +108,7 @@ The `$options` parameter in constructor accepts an associative array of options. * `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 instance on receive, default false +* `return_obj` - Return a [Message](Message.md) instance on receive, default false * `timeout` - Time out in seconds. Default 5 seconds. ```php @@ -117,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/Server.md b/docs/Server.md index 3e95dbe..f82640f 100644 --- a/docs/Server.md +++ b/docs/Server.md @@ -116,13 +116,16 @@ The `$options` parameter in constructor accepts an associative array of options. * `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 instance on receive, default false +* `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, ]); ``` From b006c8f4cf9ada87ac68861dac50a4b331a361cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 12 Dec 2020 11:54:05 +0100 Subject: [PATCH 18/19] Version 1.5 --- docs/Changelog.md | 5 ++-- docs/Client.md | 2 +- docs/Contributing.md | 6 +++- docs/Message.md | 2 ++ docs/Server.md | 2 +- lib/Client.php | 3 -- lib/Message/Message.php | 9 ++++++ tests/ClientTest.php | 10 +++++++ tests/MessageTest.php | 1 + tests/ServerTest.php | 22 +++++++++++++++ tests/mock/MockSocket.php | 4 +++ tests/scripts/client.connect-error.json | 23 +++++++++++++++ .../scripts/server.accept-error-connect.json | 18 ++++++++++++ .../server.construct-error-socket-server.json | 28 +++++++++++++++++++ 14 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 tests/scripts/client.connect-error.json create mode 100644 tests/scripts/server.accept-error-connect.json create mode 100644 tests/scripts/server.construct-error-socket-server.json diff --git a/docs/Changelog.md b/docs/Changelog.md index 2323d53..63f932d 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -11,8 +11,9 @@ * 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) - * PHP `8.0` support (@webpatser) - * Fix for unordered framgmented messages (@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) diff --git a/docs/Client.md b/docs/Client.md index 9898383..9124bf8 100644 --- a/docs/Client.md +++ b/docs/Client.md @@ -19,7 +19,7 @@ WebSocket\Client { public ping(string $payload = '') : void public pong(string $payload = '') : void public send(mixed $payload, string $opcode = 'text', bool $masked = true) : void - public receive() : ?string + public receive() : mixed public close(int $status = 1000, mixed $message = 'ttfn') : mixed public getName() : string|null diff --git a/docs/Contributing.md b/docs/Contributing.md index 47006dd..263d868 100644 --- a/docs/Contributing.md +++ b/docs/Contributing.md @@ -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/Message.md b/docs/Message.md index bb6de2f..9bd0f2b 100644 --- a/docs/Message.md +++ b/docs/Message.md @@ -26,6 +26,7 @@ WebSocket\Message\Message { public getOpcode() : string public getLength() : int + public getTimestamp() : DateTime public getContent() : string public setContent(string $payload = '') : void public hasContent() : bool @@ -54,5 +55,6 @@ 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 f82640f..7d01a41 100644 --- a/docs/Server.md +++ b/docs/Server.md @@ -23,7 +23,7 @@ WebSocket\Server { public ping(string $payload = '') : void public pong(string $payload = '') : void public send(mixed $payload, string $opcode = 'text', bool $masked = true) : void - public receive() : ?string + public receive() : mixed public close(int $status = 1000, mixed $message = 'ttfn') : mixed public getPort() : int diff --git a/lib/Client.php b/lib/Client.php index 92a459b..c85c706 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -38,9 +38,6 @@ class Client extends Base public function __construct(string $uri, array $options = []) { $this->options = array_merge(self::$default_options, $options); - if (is_null($this->options['timeout'])) { - $this->options['timeout'] = ini_get('default_socket_timeout'); - } $this->socket_uri = $uri; $this->setLogger($this->options['logger']); } diff --git a/lib/Message/Message.php b/lib/Message/Message.php index b733e74..998caa1 100644 --- a/lib/Message/Message.php +++ b/lib/Message/Message.php @@ -2,14 +2,18 @@ namespace WebSocket\Message; +use DateTime; + abstract class Message { protected $opcode; protected $payload; + protected $timestamp; public function __construct(string $payload = '') { $this->payload = $payload; + $this->timestamp = new DateTime(); } public function getOpcode(): string @@ -22,6 +26,11 @@ public function getLength(): int return strlen($this->payload); } + public function getTimestamp(): DateTime + { + return $this->timestamp; + } + public function getContent(): string { return $this->payload; diff --git a/tests/ClientTest.php b/tests/ClientTest.php index a0ffce4..d3ab410 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -261,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); diff --git a/tests/MessageTest.php b/tests/MessageTest.php index a2d4d80..2d06ab7 100644 --- a/tests/MessageTest.php +++ b/tests/MessageTest.php @@ -44,6 +44,7 @@ public function testMessage() $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()); diff --git a/tests/ServerTest.php b/tests/ServerTest.php index d5362bd..8294236 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -220,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); @@ -233,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); diff --git a/tests/mock/MockSocket.php b/tests/mock/MockSocket.php index 6a6f515..e12d6ed 100644 --- a/tests/mock/MockSocket.php +++ b/tests/mock/MockSocket.php @@ -24,6 +24,10 @@ public static function handle($function, $params = []) 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/scripts/client.connect-error.json b/tests/scripts/client.connect-error.json new file mode 100644 index 0000000..d167f23 --- /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": 2 + }, + "return": false + } +] \ No newline at end of file diff --git a/tests/scripts/server.accept-error-connect.json b/tests/scripts/server.accept-error-connect.json new file mode 100644 index 0000000..a1d3f8e --- /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": 8 + }, + "return": false + }, + { + "function": "fclose", + "params": [ + false + ], + "return": true + } +] \ No newline at end of file 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..3d65b58 --- /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": 2 + }, + "return": false + }, + { + "function": "stream_socket_server", + "params": [ + "tcp://0.0.0.0:10000", + null, + null + ], + "error": { + "msg": "A PHP error", + "type": 8 + }, + "return": false + } +] \ No newline at end of file From d5d3ba1d1cb7283090fdcb99d7116f7bf8973de1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 12 Dec 2020 12:26:06 +0100 Subject: [PATCH 19/19] Version 1.5 --- tests/scripts/client.connect-error.json | 2 +- tests/scripts/server.accept-error-connect.json | 2 +- tests/scripts/server.construct-error-socket-server.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/scripts/client.connect-error.json b/tests/scripts/client.connect-error.json index d167f23..e6c523d 100644 --- a/tests/scripts/client.connect-error.json +++ b/tests/scripts/client.connect-error.json @@ -16,7 +16,7 @@ ], "error": { "msg": "A PHP error", - "type": 2 + "type": 512 }, "return": false } diff --git a/tests/scripts/server.accept-error-connect.json b/tests/scripts/server.accept-error-connect.json index a1d3f8e..f0fef27 100644 --- a/tests/scripts/server.accept-error-connect.json +++ b/tests/scripts/server.accept-error-connect.json @@ -4,7 +4,7 @@ "params": [], "error": { "msg": "A PHP error", - "type": 8 + "type": 512 }, "return": false }, diff --git a/tests/scripts/server.construct-error-socket-server.json b/tests/scripts/server.construct-error-socket-server.json index 3d65b58..3f4909f 100644 --- a/tests/scripts/server.construct-error-socket-server.json +++ b/tests/scripts/server.construct-error-socket-server.json @@ -8,7 +8,7 @@ ], "error": { "msg": "A PHP error", - "type": 2 + "type": 512 }, "return": false }, @@ -21,7 +21,7 @@ ], "error": { "msg": "A PHP error", - "type": 8 + "type": 512 }, "return": false }