From 0be85dcb08fc285363e2b2647e59401d7b223b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 13 Feb 2021 14:02:14 +0100 Subject: [PATCH 01/21] v1.6 Connection class --- lib/Client.php | 20 ++- lib/Connection.php | 123 +++++++++++++++++++ lib/Server.php | 21 ++-- tests/scripts/client.connect-persistent.json | 7 -- 4 files changed, 151 insertions(+), 20 deletions(-) create mode 100644 lib/Connection.php diff --git a/lib/Client.php b/lib/Client.php index d6bfeab..7439301 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -25,6 +25,7 @@ class Client extends Base ]; protected $socket_uri; + protected $connection; /** * @param string $uri A ws/wss-URI @@ -44,10 +45,15 @@ public function __construct(string $uri, array $options = []) public function __destruct() { - if ($this->isConnected() && get_resource_type($this->socket) !== 'persistent stream') { - fclose($this->socket); + if ( + $this->connection + && $this->connection->isConnected() + && $this->connection->getType() !== 'persistent stream' + ) { + $this->connection->close(); } $this->socket = null; + $this->connection = null; } /** @@ -122,15 +128,17 @@ protected function connect(): void restore_error_handler(); - if (!$this->isConnected()) { + $this->connection = new Connection($this->socket); + + if (!$this->connection->isConnected()) { $error = "Could not open socket to \"{$host}:{$port}\": {$errstr} ({$errno}) {$error}."; $this->logger->error($error); throw new ConnectionException($error); } - if (!$persistent || ftell($this->socket) == 0) { + if (!$persistent || $this->connection->tell() == 0) { // Set timeout on the stream as well. - stream_set_timeout($this->socket, $this->options['timeout']); + $this->connection->setTimeout($this->options['timeout']); // Generate the WebSocket key. $key = self::generateKey(); @@ -175,7 +183,7 @@ function ($key, $value) { $this->write($header); // Get server response header (terminated with double CR+LF). - $response = stream_get_line($this->socket, 1024, "\r\n\r\n"); + $response = $this->connection->getLine(1024, "\r\n\r\n"); /// @todo Handle version switching diff --git a/lib/Connection.php b/lib/Connection.php new file mode 100644 index 0000000..328fd8e --- /dev/null +++ b/lib/Connection.php @@ -0,0 +1,123 @@ +stream = $stream; + } + + public function __destruct() + { + echo "Connection.__destruct \n"; +/* + if ($this->isConnected() && $this->getType() !== 'persistent stream') { + fclose($this->stream); + } +*/ + } + + + /* ---------- Stream handler methods --------------------------------------------- */ + + public function close(): bool + { + echo "Connection.close \n"; + return fclose($this->stream); + } + + + /* ---------- Stream state methods ----------------------------------------------- */ + + public function isConnected(): bool + { + echo "Connection.isConnected \n"; + return $this->stream && in_array($this->getType(), ['stream', 'persistent stream']); + } + + public function getType(): ?string + { + echo "Connection.getType \n"; + return get_resource_type($this->stream); + } + + /** + * Get name of local socket, or null if not connected + * @return string|null + */ + public function getName(): ?string + { + echo "Connection.getName \n"; + return stream_socket_get_name($this->stream, false); + } + + /** + * Get name of remote socket, or null if not connected + * @return string|null + */ + public function getPier(): ?string + { + echo "Connection.getPier \n"; + return stream_socket_get_name($this->stream, true); + } + + public function getMeta(): array + { + echo "Connection.getMeta \n"; + return stream_get_meta_data($this->stream); + } + + public function tell(): int + { + echo "Connection.tell \n"; + $tell = ftell($this->stream); + if ($tell === false) { + throw new RuntimeException('Could not resolve stream pointer position'); + } + return $tell; + } + + public function eof(): int + { + echo "Connection.eof \n"; + return feof($this->stream); + } + + /* ---------- Stream option methods ---------------------------------------------- */ + + public function setTimeout(int $seconds, int $microseconds = 0): bool + { + echo "Connection.setTimeout \n"; + return stream_set_timeout($this->stream, $seconds, $microseconds); + } + + + /* ---------- Stream read/write methods ------------------------------------------ */ + + public function getLine(int $length, string $ending): string + { + echo "Connection.getLine \n"; + $line = stream_get_line($this->stream, $length, $ending); + if ($line === false) { + throw new RuntimeException('Could not read from stream'); + } + return $line; + } +} diff --git a/lib/Server.php b/lib/Server.php index ae9325f..e3d9661 100644 --- a/lib/Server.php +++ b/lib/Server.php @@ -26,6 +26,7 @@ class Server extends Base protected $listening; protected $request; protected $request_path; + protected $connection; /** * @param array $options @@ -63,10 +64,12 @@ public function __construct(array $options = []) public function __destruct() { - if ($this->isConnected()) { - fclose($this->socket); + if ($this->connection && $this->connection->isConnected()) { + $this->connection->close(); } + $this->socket = null; + $this->connection = null; } public function getPort(): int @@ -98,6 +101,7 @@ public function getHeader($header): ?string public function accept(): bool { $this->socket = null; + $this->connection = null; return (bool)$this->listening; } @@ -121,13 +125,16 @@ protected function connect(): void if (!$this->socket) { $this->throwException("Server failed to connect. {$error}"); } + + $this->connection = new Connection($this->socket); + if (isset($this->options['timeout'])) { - stream_set_timeout($this->socket, $this->options['timeout']); + $this->connection->setTimeout($this->options['timeout']); } $this->logger->info("Client has connected to port {port}", [ 'port' => $this->port, - 'pier' => stream_socket_get_name($this->socket, true), + 'pier' => $this->connection->getPier(), ]); $this->performHandshake(); } @@ -136,10 +143,10 @@ protected function performHandshake(): void { $request = ''; do { - $buffer = stream_get_line($this->socket, 1024, "\r\n"); + $buffer = $this->connection->getLine(1024, "\r\n"); $request .= $buffer . "\n"; - $metadata = stream_get_meta_data($this->socket); - } while (!feof($this->socket) && $metadata['unread_bytes'] > 0); + $metadata = $this->connection->getMeta(); + } while (!$this->connection->eof() && $metadata['unread_bytes'] > 0); if (!preg_match('/GET (.*) HTTP\//mUi', $request, $matches)) { $error = "No GET in request: {$request}"; diff --git a/tests/scripts/client.connect-persistent.json b/tests/scripts/client.connect-persistent.json index d95af01..fd0bf57 100644 --- a/tests/scripts/client.connect-persistent.json +++ b/tests/scripts/client.connect-persistent.json @@ -23,13 +23,6 @@ ], "return": "persistent stream" }, - { - "function": "get_resource_type", - "params": [ - "@mock-stream" - ], - "return": "persistent stream" - }, { "function": "ftell", "params": [ From 501bb0bdf6cdf4a28f3476692ba29051ec0d9cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sun, 21 Feb 2021 13:01:19 +0100 Subject: [PATCH 02/21] Rewrite --- lib/Base.php | 267 +++---------- lib/Client.php | 23 +- lib/Connection.php | 382 +++++++++++++++++-- lib/Server.php | 21 +- tests/mock/MockSocket.php | 2 + tests/scripts/client.connect-persistent.json | 7 - tests/scripts/client.destruct.json | 7 - tests/scripts/client.reconnect.json | 14 - tests/scripts/close-remote.json | 7 - tests/scripts/ping-pong.json | 7 - tests/scripts/server.close.json | 2 +- 11 files changed, 433 insertions(+), 306 deletions(-) diff --git a/lib/Base.php b/lib/Base.php index 9191c47..0b82368 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -1,7 +1,7 @@ socket && - (get_resource_type($this->socket) == 'stream' || - get_resource_type($this->socket) == 'persistent stream'); + return $this->connection && $this->connection->isConnected(); } public function setTimeout(int $timeout): void { $this->options['timeout'] = $timeout; - if ($this->isConnected()) { - stream_set_timeout($this->socket, $timeout); + $this->connection->setTimeout($timeout); } } @@ -78,6 +76,7 @@ public function send(string $payload, string $opcode = 'text', bool $masked = tr if (!$this->isConnected()) { $this->connect(); } + //$this->connection->send($payload, $opcode, $masked); if (!in_array($opcode, array_keys(self::$opcodes))) { $warning = "Bad opcode '{$opcode}'. Try 'text' or 'binary'."; @@ -92,7 +91,7 @@ public function send(string $payload, string $opcode = 'text', bool $masked = tr $chunk = $payload_chunks[$index]; $final = $index == count($payload_chunks) - 1; - $this->sendFragment($final, $chunk, $frame_opcode, $masked); + $this->connection->pushFrame([$final, $chunk, $frame_opcode, $masked]); // all fragments after the first will be marked a continuation $frame_opcode = 'continuation'; @@ -147,7 +146,7 @@ public function pong(string $payload = ''): void */ public function getName(): ?string { - return $this->isConnected() ? stream_socket_get_name($this->socket, false) : null; + return $this->isConnected() ? $this->connection->getName() : null; } /** @@ -156,7 +155,7 @@ public function getName(): ?string */ public function getPier(): ?string { - return $this->isConnected() ? stream_socket_get_name($this->socket, true) : null; + return $this->isConnected() ? $this->connection->getPier() : null; } /** @@ -172,55 +171,6 @@ public function __toString(): string ); } - /** - * Receive one message. - * Will continue reading until read message match filter settings. - * Return Message instance or string according to settings. - */ - protected function sendFragment(bool $final, string $payload, string $opcode, bool $masked): void - { - $data = ''; - - $byte_1 = $final ? 0b10000000 : 0b00000000; // Final fragment marker. - $byte_1 |= self::$opcodes[$opcode]; // Set opcode. - $data .= pack('C', $byte_1); - - $byte_2 = $masked ? 0b10000000 : 0b00000000; // Masking bit marker. - - // 7 bits of payload length... - $payload_length = strlen($payload); - if ($payload_length > 65535) { - $data .= pack('C', $byte_2 | 0b01111111); - $data .= pack('J', $payload_length); - } elseif ($payload_length > 125) { - $data .= pack('C', $byte_2 | 0b01111110); - $data .= pack('n', $payload_length); - } else { - $data .= pack('C', $byte_2 | $payload_length); - } - - // Handle masking - if ($masked) { - // generate a random mask: - $mask = ''; - for ($i = 0; $i < 4; $i++) { - $mask .= chr(rand(0, 255)); - } - $data .= $mask; - } - - // Append payload to frame: - 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() { $filter = $this->options['filter']; @@ -229,8 +179,10 @@ public function receive() } do { + //$response = $this->connection->pullFrame(); $response = $this->receiveFragment(); list ($payload, $final, $opcode) = $response; + //$this->connection->autoRespond($response, $this->is_closing); // Continuation and factual opcode $continuation = ($opcode == 'continuation'); @@ -280,59 +232,13 @@ public function receive() protected function receiveFragment(): array { - // Read the fragment "header" first, two bytes. - $data = $this->read(2); - list ($byte_1, $byte_2) = array_values(unpack('C*', $data)); - - $final = (bool)($byte_1 & 0b10000000); // Final fragment marker. - $rsv = $byte_1 & 0b01110000; // Unused bits, ignore - - // Parse opcode - $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}"; - $this->logger->warning($warning); - throw new ConnectionException($warning, ConnectionException::BAD_OPCODE); - } - $opcode = $opcode_ints[$opcode_int]; - - // Masking bit - $mask = (bool)($byte_2 & 0b10000000); - - $payload = ''; - - // Payload length - $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)); - } - } - - // Get masking key. - if ($mask) { - $masking_key = $this->read(4); - } - - // Get the actual payload, if any (might not be for e.g. close frames. - if ($payload_length > 0) { - $data = $this->read($payload_length); + $frame = $this->connection->pullFrame(); + list ($final, $payload, $opcode, $masked) = $frame; + $payload_length = strlen($payload); - if ($mask) { - // Unmask payload. - for ($i = 0; $i < $payload_length; $i++) { - $payload .= ($data[$i] ^ $masking_key[$i % 4]); - } - } else { - $payload = $data; - } - } +// $this->close_status = $this->connection->autoRespond($frame, $this->is_closing); +// $this->is_closing = false; +// return $frame; $this->logger->debug("Read '{opcode}' frame", [ 'opcode' => $opcode, @@ -340,51 +246,47 @@ protected function receiveFragment(): array 'content-length' => strlen($payload), ]); - // if we received a ping, send a pong and wait for the next message - if ($opcode === 'ping') { - $this->logger->debug("Received 'ping', sending 'pong'."); - $this->send($payload, 'pong', true); - return [$payload, true, $opcode]; - } - - // if we received a pong, wait for the next message - if ($opcode === 'pong') { - $this->logger->debug("Received 'pong'."); - return [$payload, true, $opcode]; - } - - if ($opcode === 'close') { - $status_bin = ''; - $status = ''; - // Get the close status. - $status_bin = ''; - $status = ''; - if ($payload_length > 0) { - $status_bin = $payload[0] . $payload[1]; - $status = current(unpack('n', $payload)); - $this->close_status = $status; - } - // Get additional close message - if ($payload_length >= 2) { - $payload = substr($payload, 2); - } + switch ($opcode) { + case 'ping': + // If we received a ping, respond with a pong + $this->logger->debug("Received 'ping', sending 'pong'."); + $this->connection->pushFrame([true, $payload, 'pong', $masked]); + return [$payload, true, $opcode]; + case 'close': + // If we received close, possibly acknowledge and close connection + $status_bin = ''; + $status = ''; + // Get the close status. + $status_bin = ''; + $status = ''; + if ($payload_length > 0) { + $status_bin = $payload[0] . $payload[1]; + $status = current(unpack('n', $payload)); + $this->close_status = $status; + } + // Get additional close message + if ($payload_length >= 2) { + $payload = substr($payload, 2); + } - $this->logger->debug("Received 'close', status: {$this->close_status}."); + $this->logger->debug("[connection] Received 'close', status: {$this->close_status}."); - if ($this->is_closing) { - $this->is_closing = false; // A close response, all done. - } else { - $this->send($status_bin . 'Close acknowledged: ' . $status, 'close', true); // Respond. - } + if ($this->is_closing) { + $this->is_closing = false; // A close response, all done. + } else { + $ack = "{$status_bin}Close acknowledged: {$status}"; + $this->connection->pushFrame([true, $ack, 'close', $masked]); + } - // Close the socket. - fclose($this->socket); + // Close the socket. + $this->connection->disconnect(); + $this->connection = null; - // Closing should not return message. - return [$payload, true, $opcode]; + // Closing should not return message. + return [$payload, true, $opcode]; + default: + return [$payload, $final, $opcode]; } - - return [$payload, $final, $opcode]; } /** @@ -398,12 +300,19 @@ public function close(int $status = 1000, string $message = 'ttfn'): void if (!$this->isConnected()) { return; } + //$this->connection->close($status, $message, $this); + + $status_binstr = sprintf('%016b', $status); $status_str = ''; foreach (str_split($status_binstr, 8) as $binstr) { $status_str .= chr(bindec($binstr)); } - $this->send($status_str . $message, 'close', true); +if (!$this->isConnected()) { + $this->connect(); + } + $this->connection->pushFrame([true, $status_str . $message, 'close', true]); +// $this->send($status_str . $message, 'close', true); $this->logger->debug("Closing with status: {$status_str}."); $this->is_closing = true; @@ -416,60 +325,8 @@ public function close(int $status = 1000, string $message = 'ttfn'): void public function disconnect(): void { if ($this->isConnected()) { - fclose($this->socket); - $this->socket = null; - } - } - - protected function write(string $data): void - { - $length = strlen($data); - $written = @fwrite($this->socket, $data); - if ($written === false) { - $this->throwException("Failed to write {$length} bytes."); - } - if ($written < strlen($data)) { - $this->throwException("Could only write {$written} out of {$length} bytes."); - } - $this->logger->debug("Wrote {$written} of {$length} bytes."); - } - - protected function read(string $length): string - { - $data = ''; - while (strlen($data) < $length) { - $buffer = @fread($this->socket, $length - strlen($data)); - if ($buffer === false) { - $read = strlen($data); - $this->throwException("Broken frame, read {$read} of stated {$length} bytes."); - } - if ($buffer === '') { - $this->throwException("Empty read; connection dead?"); - } - $data .= $buffer; - $read = strlen($data); - $this->logger->debug("Read {$read} of {$length} bytes."); - } - return $data; - } - - protected function throwException(string $message, int $code = 0): void - { - $meta = ['closed' => true]; - if ($this->isConnected()) { - $meta = stream_get_meta_data($this->socket); - fclose($this->socket); - $this->socket = null; - } - $json_meta = json_encode($meta); - if (!empty($meta['timed_out'])) { - $this->logger->error($message, $meta); - throw new TimeoutException($message, ConnectionException::TIMED_OUT, $meta); - } - if (!empty($meta['eof'])) { - $code = ConnectionException::EOF; + $this->connection->disconnect(); } - $this->logger->error($message, $meta); - throw new ConnectionException($message, $code, $meta); + $this->connection = null; } } diff --git a/lib/Client.php b/lib/Client.php index 7439301..09ea7f1 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -1,7 +1,7 @@ setLogger($this->options['logger']); } - public function __destruct() - { - if ( - $this->connection - && $this->connection->isConnected() - && $this->connection->getType() !== 'persistent stream' - ) { - $this->connection->close(); - } - $this->socket = null; - $this->connection = null; - } - /** * Perform WebSocket handshake */ @@ -117,7 +103,7 @@ protected function connect(): void }, E_ALL); // Open the socket. - $this->socket = stream_socket_client( + $socket = stream_socket_client( "{$host_uri}:{$port}", $errno, $errstr, @@ -128,7 +114,8 @@ protected function connect(): void restore_error_handler(); - $this->connection = new Connection($this->socket); + $this->connection = new Connection($socket, $this->options); + $this->connection->setLogger($this->logger); if (!$this->connection->isConnected()) { $error = "Could not open socket to \"{$host}:{$port}\": {$errstr} ({$errno}) {$error}."; @@ -180,7 +167,7 @@ function ($key, $value) { ) . "\r\n\r\n"; // Send headers. - $this->write($header); + $this->connection->write($header); // Get server response header (terminated with double CR+LF). $response = $this->connection->getLine(1024, "\r\n\r\n"); diff --git a/lib/Connection.php b/lib/Connection.php index 328fd8e..0062d51 100644 --- a/lib/Connection.php +++ b/lib/Connection.php @@ -9,115 +9,441 @@ namespace WebSocket; -use RuntimeException; +use Psr\Log\{LoggerAwareInterface, LoggerInterface, NullLogger}; -class Connection +class Connection implements LoggerAwareInterface { + protected static $opcodes = [ + 'continuation' => 0, + 'text' => 1, + 'binary' => 2, + 'close' => 8, + 'ping' => 9, + 'pong' => 10, + ]; - protected $stream; + private $stream; + private $logger; + private $read_buffer; + private $options = []; + + private $uid; /* ---------- Construct & Destruct ----------------------------------------------- */ - public function __construct($stream) + public function __construct($stream, array $options = []) { - echo "Connection.__construct \n"; + $this->uid = rand(100, 999); + echo "Connection.__construct {$this->uid}\n"; $this->stream = $stream; + $this->options = $options; + $this->logger = new NullLogger(); } public function __destruct() { - echo "Connection.__destruct \n"; -/* - if ($this->isConnected() && $this->getType() !== 'persistent stream') { + echo "Connection.__destruct {$this->uid}\n"; + if ($this->getType() === 'stream') { fclose($this->stream); } -*/ } - /* ---------- Stream handler methods --------------------------------------------- */ + public function send(string $payload, string $opcode = 'text', bool $masked = true): void + { + if (!in_array($opcode, array_keys(self::$opcodes))) { + $warning = "Bad opcode '{$opcode}'. Try 'text' or 'binary'."; + $this->logger->warning($warning); + throw new BadOpcodeException($warning); + } + + $payload_chunks = str_split($payload, $this->options['fragment_size']); + $frame_opcode = $opcode; - public function close(): bool + for ($index = 0; $index < count($payload_chunks); ++$index) { + $chunk = $payload_chunks[$index]; + $final = $index == count($payload_chunks) - 1; + + $this->pushFrame([$final, $chunk, $frame_opcode, $masked]); + + // all fragments after the first will be marked a continuation + $frame_opcode = 'continuation'; + } + + $this->logger->info("Sent '{$opcode}' message", [ + 'opcode' => $opcode, + 'content-length' => strlen($payload), + 'frames' => count($payload_chunks), + ]); + } + + /** + * Tell the socket to close. + * + * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4 + * @param string $message A closing message, max 125 bytes. + */ + public function close(int $status = 1000, string $message = 'ttfn', $c): void { - echo "Connection.close \n"; - return fclose($this->stream); + $status_binstr = sprintf('%016b', $status); + $status_str = ''; + foreach (str_split($status_binstr, 8) as $binstr) { + $status_str .= chr(bindec($binstr)); + } + $c->send($status_str . $message, 'close', true); +// $this->pushFrame([true, $status_str . $message, 'close', true]); + $this->logger->debug("Closing with status: {$status_str}."); + + $this->is_closing = true; + $c->receive(); // Receiving a close frame will close the socket now. + } + + + /* ---------- Frame I/O methods -------------------------------------------------- */ + + // Pull frame from stream + public function pullFrame(): array + { + // Read the fragment "header" first, two bytes. + $data = $this->read(2); + list ($byte_1, $byte_2) = array_values(unpack('C*', $data)); + $final = (bool)($byte_1 & 0b10000000); // Final fragment marker. + $rsv = $byte_1 & 0b01110000; // Unused bits, ignore + + // Parse opcode + $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}"; + $this->logger->warning($warning); + throw new ConnectionException($warning, ConnectionException::BAD_OPCODE); + } + $opcode = $opcode_ints[$opcode_int]; + + // Masking bit + $masked = (bool)($byte_2 & 0b10000000); + + $payload = ''; + + // Payload length + $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)); + } + } + + // Get masking key. + if ($masked) { + $masking_key = $this->read(4); + } + + // Get the actual payload, if any (might not be for e.g. close frames. + if ($payload_length > 0) { + $data = $this->read($payload_length); + + if ($masked) { + // Unmask payload. + for ($i = 0; $i < $payload_length; $i++) { + $payload .= ($data[$i] ^ $masking_key[$i % 4]); + } + } else { + $payload = $data; + } + } + + $this->logger->debug("[connection] Pulled '{opcode}' frame", [ + 'opcode' => $opcode, + 'final' => $final, + 'content-length' => strlen($payload), + ]); + return [$final, $payload, $opcode, $masked]; + } + + // Push frame to stream + public function pushFrame(array $frame): void + { + list ($final, $payload, $opcode, $masked) = $frame; + $data = ''; + + $byte_1 = $final ? 0b10000000 : 0b00000000; // Final fragment marker. + $byte_1 |= self::$opcodes[$opcode]; // Set opcode. + $data .= pack('C', $byte_1); + + $byte_2 = $masked ? 0b10000000 : 0b00000000; // Masking bit marker. + + // 7 bits of payload length... + $payload_length = strlen($payload); + if ($payload_length > 65535) { + $data .= pack('C', $byte_2 | 0b01111111); + $data .= pack('J', $payload_length); + } elseif ($payload_length > 125) { + $data .= pack('C', $byte_2 | 0b01111110); + $data .= pack('n', $payload_length); + } else { + $data .= pack('C', $byte_2 | $payload_length); + } + + // Handle masking + if ($masked) { + // generate a random mask: + $mask = ''; + for ($i = 0; $i < 4; $i++) { + $mask .= chr(rand(0, 255)); + } + $data .= $mask; + } + + // Append payload to frame: + for ($i = 0; $i < $payload_length; $i++) { + $data .= ($masked === true) ? $payload[$i] ^ $mask[$i % 4] : $payload[$i]; + } + $this->write($data); + $this->logger->debug("[connection] Pushed '{$opcode}' frame", [ + 'opcode' => $opcode, + 'final' => $final, + 'content-length' => strlen($payload), + ]); + } + + // Trigger auto response for frame + public function autoRespond(array $frame, bool $is_closing) + { + list ($final, $payload, $opcode, $masked) = $frame; + $payload_length = strlen($payload); + + switch ($opcode) { + case 'ping': + // If we received a ping, respond with a pong + $this->logger->debug("[connection] Received 'ping', sending 'pong'."); + $this->pushFrame([true, $payload, 'pong', $masked]); + return null; + case 'close': + // If we received close, possibly acknowledge and close connection + $status_bin = ''; + $status = ''; + // Get the close status. + $status_bin = ''; + $status = ''; + if ($payload_length > 0) { + $status_bin = $payload[0] . $payload[1]; + $status = current(unpack('n', $payload)); + $close_status = $status; + } + // Get additional close message + if ($payload_length >= 2) { + $payload = substr($payload, 2); + } + $this->logger->debug("[connection] Received 'close', status: {$close_status}."); + if (!$is_closing) { + $ack = "{$status_bin} Close acknowledged: {$status}"; + $this->pushFrame([true, $ack, 'close', $masked]); + } else { + $is_closing = false; // A close response, all done. + } + $this->disconnect(); + return [$is_closing, $close_status]; + default: + return null; // No auto response + } } - /* ---------- Stream state methods ----------------------------------------------- */ + /* ---------- Stream I/O methods ------------------------------------------------- */ + /** + * Close connection stream. + * @return bool + */ + public function disconnect(): bool + { + echo "Connection.disconnect {$this->uid}\n"; + $this->logger->debug('Closing connection'); + return fclose($this->stream); + } + + /** + * If connected to stream. + * @return bool + */ public function isConnected(): bool { - echo "Connection.isConnected \n"; - return $this->stream && in_array($this->getType(), ['stream', 'persistent stream']); + echo "Connection.isConnected {$this->uid} \n"; + return in_array($this->getType(), ['stream', 'persistent stream']); } + /** + * Return type of connection. + * @return string|null Type of connection or null if invalid type. + */ public function getType(): ?string { - echo "Connection.getType \n"; + echo "Connection.getType {$this->uid} \n"; return get_resource_type($this->stream); } /** - * Get name of local socket, or null if not connected + * Get name of local socket, or null if not connected. * @return string|null */ public function getName(): ?string { - echo "Connection.getName \n"; return stream_socket_get_name($this->stream, false); } /** - * Get name of remote socket, or null if not connected + * Get name of remote socket, or null if not connected. * @return string|null */ public function getPier(): ?string { - echo "Connection.getPier \n"; return stream_socket_get_name($this->stream, true); } + /** + * Get meta data for connection. + * @return array + */ public function getMeta(): array { - echo "Connection.getMeta \n"; return stream_get_meta_data($this->stream); } + /** + * Returns current position of stream pointer. + * @return int + * @throws ConnectionException + */ public function tell(): int { - echo "Connection.tell \n"; $tell = ftell($this->stream); if ($tell === false) { - throw new RuntimeException('Could not resolve stream pointer position'); + $this->throwException('Could not resolve stream pointer position'); } return $tell; } + /** + * If stream pointer is at end of file. + * @return bool + */ public function eof(): int { - echo "Connection.eof \n"; return feof($this->stream); } + /* ---------- Stream option methods ---------------------------------------------- */ + /** + * Set time out on connection. + * @param int $seconds Timeout part in seconds + * @param int $microseconds Timeout part in microseconds + * @return bool + */ public function setTimeout(int $seconds, int $microseconds = 0): bool { - echo "Connection.setTimeout \n"; + $this->logger->debug("Setting timeout {$seconds}:{$microseconds} seconds"); return stream_set_timeout($this->stream, $seconds, $microseconds); } /* ---------- Stream read/write methods ------------------------------------------ */ + /** + * Read line from stream. + * @param int $length Maximum number of bytes to read + * @param string $ending Line delimiter + * @return string Read data + */ public function getLine(int $length, string $ending): string { - echo "Connection.getLine \n"; $line = stream_get_line($this->stream, $length, $ending); if ($line === false) { - throw new RuntimeException('Could not read from stream'); + $this->throwException('Could not read from stream'); } + $read = strlen($line); + $this->logger->debug("Read {$read} bytes of line."); return $line; } + + /** + * Read characters from stream. + * @param int $length Maximum number of bytes to read + * @return string Read data + */ + public function read(string $length): string + { + $data = ''; + while (strlen($data) < $length) { + $buffer = fread($this->stream, $length - strlen($data)); + if ($buffer === false) { + $read = strlen($data); + $this->throwException("Broken frame, read {$read} of stated {$length} bytes."); + } + if ($buffer === '') { + $this->throwException("Empty read; connection dead?"); + } + $data .= $buffer; + $read = strlen($data); + $this->logger->debug("Read {$read} of {$length} bytes."); + } + return $data; + } + + /** + * Write characters to stream. + * @param string $data Data to read + */ + public function write(string $data): void + { + $length = strlen($data); + $written = fwrite($this->stream, $data); + if ($written === false) { + $this->throwException("Failed to write {$length} bytes."); + } + if ($written < strlen($data)) { + $this->throwException("Could only write {$written} out of {$length} bytes."); + } + $this->logger->debug("Wrote {$written} of {$length} bytes."); + } + + + /* ---------- PSR-3 Logger implemetation ----------------------------------------- */ + + /** + * Set logger. + * @param LoggerInterface Logger implementation + */ + public function setLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + } + + + /* ---------- Internal helper methods -------------------------------------------- */ + + protected function throwException(string $message, int $code = 0): void + { + $meta = ['closed' => true]; + if ($this->isConnected()) { + $meta = $this->getMeta(); + $this->disconnect(); + } + $json_meta = json_encode($meta); + if (!empty($meta['timed_out'])) { + $this->logger->error($message, $meta); + throw new TimeoutException($message, ConnectionException::TIMED_OUT, $meta); + } + if (!empty($meta['eof'])) { + $code = ConnectionException::EOF; + } + $this->logger->error($message, $meta); + throw new ConnectionException($message, $code, $meta); + } } diff --git a/lib/Server.php b/lib/Server.php index e3d9661..3089a2b 100644 --- a/lib/Server.php +++ b/lib/Server.php @@ -1,7 +1,7 @@ connection && $this->connection->isConnected()) { - $this->connection->close(); + $this->connection->disconnect(); } - - $this->socket = null; $this->connection = null; } @@ -100,7 +97,6 @@ public function getHeader($header): ?string public function accept(): bool { - $this->socket = null; $this->connection = null; return (bool)$this->listening; } @@ -115,18 +111,19 @@ protected function connect(): void }, E_ALL); if (isset($this->options['timeout'])) { - $this->socket = stream_socket_accept($this->listening, $this->options['timeout']); + $socket = stream_socket_accept($this->listening, $this->options['timeout']); } else { - $this->socket = stream_socket_accept($this->listening); + $socket = stream_socket_accept($this->listening); } restore_error_handler(); - if (!$this->socket) { - $this->throwException("Server failed to connect. {$error}"); + if (!$socket) { + throw new ConnectionException("Server failed to connect. {$error}"); } - $this->connection = new Connection($this->socket); + $this->connection = new Connection($socket, $this->options); + $this->connection->setLogger($this->logger); if (isset($this->options['timeout'])) { $this->connection->setTimeout($this->options['timeout']); @@ -177,7 +174,7 @@ protected function performHandshake(): void . "Sec-WebSocket-Accept: $response_key\r\n" . "\r\n"; - $this->write($header); + $this->connection->write($header); $this->logger->debug("Handshake on {$get_uri}"); } } diff --git a/tests/mock/MockSocket.php b/tests/mock/MockSocket.php index e12d6ed..d430d18 100644 --- a/tests/mock/MockSocket.php +++ b/tests/mock/MockSocket.php @@ -17,6 +17,7 @@ class MockSocket public static function handle($function, $params = []) { $current = array_shift(self::$queue); +echo "$function > ".json_encode($current)."\n"; if ($function == 'get_resource_type' && is_null($current)) { return null; // Catch destructors } @@ -46,6 +47,7 @@ public static function isEmpty(): bool // Initialize call queue public static function initialize($op_file, $asserter): void { +echo " --------- $op_file ------------- \n"; $file = dirname(__DIR__) . "/scripts/{$op_file}.json"; self::$queue = json_decode(file_get_contents($file), true); self::$asserter = $asserter; diff --git a/tests/scripts/client.connect-persistent.json b/tests/scripts/client.connect-persistent.json index fd0bf57..ef3059f 100644 --- a/tests/scripts/client.connect-persistent.json +++ b/tests/scripts/client.connect-persistent.json @@ -70,13 +70,6 @@ ], "return": "persistent stream" }, - { - "function": "get_resource_type", - "params": [ - "@mock-stream" - ], - "return": "persistent stream" - }, { "function": "fclose", "params": [ diff --git a/tests/scripts/client.destruct.json b/tests/scripts/client.destruct.json index c04755b..739e6fb 100644 --- a/tests/scripts/client.destruct.json +++ b/tests/scripts/client.destruct.json @@ -1,11 +1,4 @@ [ - { - "function": "get_resource_type", - "params": [ - "@mock-stream" - ], - "return": "stream" - }, { "function": "get_resource_type", "params": [ diff --git a/tests/scripts/client.reconnect.json b/tests/scripts/client.reconnect.json index 9d505fe..784d24e 100644 --- a/tests/scripts/client.reconnect.json +++ b/tests/scripts/client.reconnect.json @@ -1,18 +1,4 @@ [ - { - "function": "get_resource_type", - "params": [ - "@mock-stream" - ], - "return": "Unknown" - }, - { - "function": "get_resource_type", - "params": [ - "@mock-stream" - ], - "return": "stream" - }, { "function": "stream_context_create", "params": [], diff --git a/tests/scripts/close-remote.json b/tests/scripts/close-remote.json index d2eea02..42be0f5 100644 --- a/tests/scripts/close-remote.json +++ b/tests/scripts/close-remote.json @@ -33,13 +33,6 @@ "return-op": "chr-array", "return": [117, 35, 170, 152, 89, 60, 128, 154, 81] }, - { - "function": "get_resource_type", - "params": [ - "@mock-stream" - ], - "return": "stream" - }, { "function": "fwrite", "params": [], diff --git a/tests/scripts/ping-pong.json b/tests/scripts/ping-pong.json index b647e91..d253f8f 100644 --- a/tests/scripts/ping-pong.json +++ b/tests/scripts/ping-pong.json @@ -106,13 +106,6 @@ "return-op": "chr-array", "return": [247, 33, 169, 172, 218, 57, 224, 185, 221, 35, 167] }, - { - "function": "get_resource_type", - "params": [ - "@mock-stream" - ], - "return": "stream" - }, { "function": "fwrite", "params": [ diff --git a/tests/scripts/server.close.json b/tests/scripts/server.close.json index b6e045c..787cc7b 100644 --- a/tests/scripts/server.close.json +++ b/tests/scripts/server.close.json @@ -5,7 +5,7 @@ "params": [ "@mock-stream" ], - "return": true + "return": "stream" }, { "function": "get_resource_type", From 58df973cef79a7d195b84a6cea45f62523bf04e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Tue, 23 Feb 2021 22:14:32 +0100 Subject: [PATCH 03/21] More --- lib/Base.php | 90 ++--------------------------- lib/Client.php | 2 + lib/Connection.php | 54 ++++++++++++----- tests/ServerTest.php | 1 + tests/scripts/client.close.json | 14 ----- tests/scripts/client.reconnect.json | 14 +++++ tests/scripts/server.close.json | 14 ----- 7 files changed, 61 insertions(+), 128 deletions(-) diff --git a/lib/Base.php b/lib/Base.php index 0b82368..8d71885 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -14,12 +14,9 @@ class Base implements LoggerAwareInterface { - protected $socket; protected $connection; protected $options = []; - protected $is_closing = false; protected $last_opcode = null; - protected $close_status = null; protected $logger; private $read_buffer; @@ -39,7 +36,7 @@ public function getLastOpcode(): ?string public function getCloseStatus(): ?int { - return $this->close_status; + return $this->connection ? $this->connection->getCloseStatus() : null; } public function isConnected(): bool @@ -179,10 +176,9 @@ public function receive() } do { - //$response = $this->connection->pullFrame(); - $response = $this->receiveFragment(); - list ($payload, $final, $opcode) = $response; - //$this->connection->autoRespond($response, $this->is_closing); + $frame = $this->connection->pullFrame(); + $frame = $this->connection->autoRespond($frame); + list ($final, $payload, $opcode, $masked) = $frame; // Continuation and factual opcode $continuation = ($opcode == 'continuation'); @@ -230,65 +226,6 @@ public function receive() : $payload; } - protected function receiveFragment(): array - { - $frame = $this->connection->pullFrame(); - list ($final, $payload, $opcode, $masked) = $frame; - $payload_length = strlen($payload); - -// $this->close_status = $this->connection->autoRespond($frame, $this->is_closing); -// $this->is_closing = false; -// return $frame; - - $this->logger->debug("Read '{opcode}' frame", [ - 'opcode' => $opcode, - 'final' => $final, - 'content-length' => strlen($payload), - ]); - - switch ($opcode) { - case 'ping': - // If we received a ping, respond with a pong - $this->logger->debug("Received 'ping', sending 'pong'."); - $this->connection->pushFrame([true, $payload, 'pong', $masked]); - return [$payload, true, $opcode]; - case 'close': - // If we received close, possibly acknowledge and close connection - $status_bin = ''; - $status = ''; - // Get the close status. - $status_bin = ''; - $status = ''; - if ($payload_length > 0) { - $status_bin = $payload[0] . $payload[1]; - $status = current(unpack('n', $payload)); - $this->close_status = $status; - } - // Get additional close message - if ($payload_length >= 2) { - $payload = substr($payload, 2); - } - - $this->logger->debug("[connection] Received 'close', status: {$this->close_status}."); - - if ($this->is_closing) { - $this->is_closing = false; // A close response, all done. - } else { - $ack = "{$status_bin}Close acknowledged: {$status}"; - $this->connection->pushFrame([true, $ack, 'close', $masked]); - } - - // Close the socket. - $this->connection->disconnect(); - $this->connection = null; - - // Closing should not return message. - return [$payload, true, $opcode]; - default: - return [$payload, $final, $opcode]; - } - } - /** * Tell the socket to close. * @@ -300,23 +237,7 @@ public function close(int $status = 1000, string $message = 'ttfn'): void if (!$this->isConnected()) { return; } - //$this->connection->close($status, $message, $this); - - - $status_binstr = sprintf('%016b', $status); - $status_str = ''; - foreach (str_split($status_binstr, 8) as $binstr) { - $status_str .= chr(bindec($binstr)); - } -if (!$this->isConnected()) { - $this->connect(); - } - $this->connection->pushFrame([true, $status_str . $message, 'close', true]); -// $this->send($status_str . $message, 'close', true); - $this->logger->debug("Closing with status: {$status_str}."); - - $this->is_closing = true; - $this->receive(); // Receiving a close frame will close the socket now. + $this->connection->close($status, $message, $this); } /** @@ -327,6 +248,5 @@ public function disconnect(): void if ($this->isConnected()) { $this->connection->disconnect(); } - $this->connection = null; } } diff --git a/lib/Client.php b/lib/Client.php index 09ea7f1..ad7287c 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -47,6 +47,8 @@ public function __construct(string $uri, array $options = []) */ protected function connect(): void { + $this->connection = null; + $url_parts = parse_url($this->socket_uri); if (empty($url_parts) || empty($url_parts['scheme']) || empty($url_parts['host'])) { $error = "Invalid url '{$this->socket_uri}' provided."; diff --git a/lib/Connection.php b/lib/Connection.php index 0062d51..ce6c1cd 100644 --- a/lib/Connection.php +++ b/lib/Connection.php @@ -27,6 +27,9 @@ class Connection implements LoggerAwareInterface private $read_buffer; private $options = []; + protected $is_closing = false; + protected $close_status = null; + private $uid; /* ---------- Construct & Destruct ----------------------------------------------- */ @@ -48,6 +51,25 @@ public function __destruct() } } + /** + * Get string representation of instance + * @return string String representation + */ + public function __toString(): string + { + return sprintf( + "%s(%s)", + get_class($this), + 'closed' + ); + } + + + + public function getCloseStatus(): ?int + { + return $this->close_status; + } public function send(string $payload, string $opcode = 'text', bool $masked = true): void { @@ -85,17 +107,21 @@ public function send(string $payload, string $opcode = 'text', bool $masked = tr */ public function close(int $status = 1000, string $message = 'ttfn', $c): void { + echo "Connection.close {$this->uid}\n"; + if (!$this->isConnected()) { + return; + } $status_binstr = sprintf('%016b', $status); $status_str = ''; foreach (str_split($status_binstr, 8) as $binstr) { $status_str .= chr(bindec($binstr)); } - $c->send($status_str . $message, 'close', true); -// $this->pushFrame([true, $status_str . $message, 'close', true]); + $this->pushFrame([true, $status_str . $message, 'close', true]); $this->logger->debug("Closing with status: {$status_str}."); $this->is_closing = true; - $c->receive(); // Receiving a close frame will close the socket now. + $frame = $this->pullFrame(); + $this->autoRespond($frame); } @@ -212,7 +238,7 @@ public function pushFrame(array $frame): void } // Trigger auto response for frame - public function autoRespond(array $frame, bool $is_closing) + public function autoRespond(array $frame) { list ($final, $payload, $opcode, $masked) = $frame; $payload_length = strlen($payload); @@ -222,34 +248,32 @@ public function autoRespond(array $frame, bool $is_closing) // If we received a ping, respond with a pong $this->logger->debug("[connection] Received 'ping', sending 'pong'."); $this->pushFrame([true, $payload, 'pong', $masked]); - return null; + return [$final, $payload, $opcode, $masked]; case 'close': // If we received close, possibly acknowledge and close connection $status_bin = ''; $status = ''; - // Get the close status. - $status_bin = ''; - $status = ''; if ($payload_length > 0) { $status_bin = $payload[0] . $payload[1]; $status = current(unpack('n', $payload)); - $close_status = $status; + $this->close_status = $status; } // Get additional close message if ($payload_length >= 2) { $payload = substr($payload, 2); } - $this->logger->debug("[connection] Received 'close', status: {$close_status}."); - if (!$is_closing) { - $ack = "{$status_bin} Close acknowledged: {$status}"; + + $this->logger->debug("[connection] Received 'close', status: {$status}."); + if (!$this->is_closing) { + $ack = "{$status_bin}Close acknowledged: {$status}"; $this->pushFrame([true, $ack, 'close', $masked]); } else { - $is_closing = false; // A close response, all done. + $this->is_closing = false; // A close response, all done. } $this->disconnect(); - return [$is_closing, $close_status]; + return [$final, $payload, $opcode, $masked]; default: - return null; // No auto response + return [$final, $payload, $opcode, $masked]; } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 8294236..a80b60e 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -23,6 +23,7 @@ public function testServerMasked(): void { MockSocket::initialize('server.construct', $this); $server = new Server(); +$server->setLogger(new EchoLog()); $this->assertTrue(MockSocket::isEmpty()); MockSocket::initialize('server.accept', $this); $server->accept(); diff --git a/tests/scripts/client.close.json b/tests/scripts/client.close.json index d449c17..c91c0d6 100644 --- a/tests/scripts/client.close.json +++ b/tests/scripts/client.close.json @@ -25,13 +25,6 @@ "params": [], "return": 12 }, - { - "function": "get_resource_type", - "params": [ - "@mock-stream" - ], - "return": "stream" - }, { "function": "fread", "params": [ @@ -65,12 +58,5 @@ "@mock-stream" ], "return":true - }, - { - "function": "get_resource_type", - "params": [ - "@mock-stream" - ], - "return": "Unknown" } ] \ No newline at end of file diff --git a/tests/scripts/client.reconnect.json b/tests/scripts/client.reconnect.json index 784d24e..f305848 100644 --- a/tests/scripts/client.reconnect.json +++ b/tests/scripts/client.reconnect.json @@ -1,4 +1,18 @@ [ + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "unknown" + }, + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "unknown" + }, { "function": "stream_context_create", "params": [], diff --git a/tests/scripts/server.close.json b/tests/scripts/server.close.json index 787cc7b..dc37429 100644 --- a/tests/scripts/server.close.json +++ b/tests/scripts/server.close.json @@ -19,13 +19,6 @@ "params": [], "return":12 }, - { - "function": "get_resource_type", - "params": [ - "@mock-stream" - ], - "return": "stream" - }, { "function": "fread", "params": [ @@ -59,12 +52,5 @@ "@mock-stream" ], "return": true - }, - { - "function": "get_resource_type", - "params": [ - "@mock-stream" - ], - "return": "Unknown" } ] \ No newline at end of file From 326944cb91131540a1c1dac4128d89ecded4384b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Wed, 24 Feb 2021 19:09:54 +0100 Subject: [PATCH 04/21] More --- lib/Base.php | 2 +- lib/Connection.php | 4 ++-- tests/ServerTest.php | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/Base.php b/lib/Base.php index 8d71885..d27139b 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -237,7 +237,7 @@ public function close(int $status = 1000, string $message = 'ttfn'): void if (!$this->isConnected()) { return; } - $this->connection->close($status, $message, $this); + $this->connection->close($status, $message); } /** diff --git a/lib/Connection.php b/lib/Connection.php index ce6c1cd..ba036c3 100644 --- a/lib/Connection.php +++ b/lib/Connection.php @@ -36,7 +36,7 @@ class Connection implements LoggerAwareInterface public function __construct($stream, array $options = []) { - $this->uid = rand(100, 999); + $this->uid = rand(100, 999); echo "Connection.__construct {$this->uid}\n"; $this->stream = $stream; $this->options = $options; @@ -105,7 +105,7 @@ public function send(string $payload, string $opcode = 'text', bool $masked = tr * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4 * @param string $message A closing message, max 125 bytes. */ - public function close(int $status = 1000, string $message = 'ttfn', $c): void + public function close(int $status = 1000, string $message = 'ttfn'): void { echo "Connection.close {$this->uid}\n"; if (!$this->isConnected()) { diff --git a/tests/ServerTest.php b/tests/ServerTest.php index a80b60e..8294236 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -23,7 +23,6 @@ public function testServerMasked(): void { MockSocket::initialize('server.construct', $this); $server = new Server(); -$server->setLogger(new EchoLog()); $this->assertTrue(MockSocket::isEmpty()); MockSocket::initialize('server.accept', $this); $server->accept(); From c68a53886417d5361f16558c276ecbd071127321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Fri, 5 Mar 2021 15:20:06 +0100 Subject: [PATCH 05/21] Message --- lib/Connection.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/Connection.php b/lib/Connection.php index 0989d5b..edb21d1 100644 --- a/lib/Connection.php +++ b/lib/Connection.php @@ -88,7 +88,10 @@ public function close(int $status = 1000, string $message = 'ttfn'): void foreach (str_split($status_binstr, 8) as $binstr) { $status_str .= chr(bindec($binstr)); } - $this->pushFrame([true, $status_str . $message, 'close', true]); + $factory = new Factory(); + $message = $factory->create('close', $status_str . $message); + $this->pushMessage($message, true); + $this->logger->debug("Closing with status: {$status_str}."); $this->is_closing = true; @@ -283,12 +286,14 @@ public function autoRespond(array $frame) { list ($final, $payload, $opcode, $masked) = $frame; $payload_length = strlen($payload); + $factory = new Factory(); switch ($opcode) { case 'ping': // If we received a ping, respond with a pong $this->logger->debug("[connection] Received 'ping', sending 'pong'."); - $this->pushFrame([true, $payload, 'pong', $masked]); + $message = $factory->create('pong', $payload); + $this->pushMessage($message, $masked); return [$final, $payload, $opcode, $masked]; case 'close': // If we received close, possibly acknowledge and close connection @@ -307,7 +312,8 @@ public function autoRespond(array $frame) $this->logger->debug("[connection] Received 'close', status: {$status}."); if (!$this->is_closing) { $ack = "{$status_bin}Close acknowledged: {$status}"; - $this->pushFrame([true, $ack, 'close', $masked]); + $message = $factory->create('close', $ack); + $this->pushMessage($message, $masked); } else { $this->is_closing = false; // A close response, all done. } From 3d370e70dcf99c43c8182daaacd771fb986dc158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Fri, 5 Mar 2021 16:02:01 +0100 Subject: [PATCH 06/21] Wait for ack --- examples/echoserver.php | 2 +- lib/Client.php | 8 +++++++- lib/Connection.php | 10 +++++++--- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/examples/echoserver.php b/examples/echoserver.php index 231c4c9..eb5ebef 100644 --- a/examples/echoserver.php +++ b/examples/echoserver.php @@ -16,7 +16,7 @@ error_reporting(-1); -echo "> Random server\n"; +echo "> Echo server\n"; // Server options specified or random $options = array_merge([ diff --git a/lib/Client.php b/lib/Client.php index ad7287c..6a81522 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -116,10 +116,16 @@ protected function connect(): void restore_error_handler(); + if (!$socket) { + $error = "Could not open socket to \"{$host}:{$port}\": {$errstr} ({$errno}) {$error}."; + $this->logger->error($error); + throw new ConnectionException($error); + } + $this->connection = new Connection($socket, $this->options); $this->connection->setLogger($this->logger); - if (!$this->connection->isConnected()) { + if (!$this->isConnected()) { $error = "Could not open socket to \"{$host}:{$port}\": {$errstr} ({$errno}) {$error}."; $this->logger->error($error); throw new ConnectionException($error); diff --git a/lib/Connection.php b/lib/Connection.php index edb21d1..f06a945 100644 --- a/lib/Connection.php +++ b/lib/Connection.php @@ -92,11 +92,15 @@ public function close(int $status = 1000, string $message = 'ttfn'): void $message = $factory->create('close', $status_str . $message); $this->pushMessage($message, true); - $this->logger->debug("Closing with status: {$status_str}."); + $this->logger->debug("Closing with status: {$status}."); $this->is_closing = true; - $frame = $this->pullFrame(); - $this->autoRespond($frame); + while (true) { + $message = $this->pullMessage(); + if ($message->getOpcode() == 'close') { + return; + } + } } From e219037324c12416b6420bd2b0099e92d5dcf329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Fri, 5 Mar 2021 17:51:57 +0100 Subject: [PATCH 07/21] Ensure coverage --- lib/Base.php | 12 +++-- lib/Client.php | 2 +- lib/Connection.php | 15 +----- tests/ClientTest.php | 29 +++++++++++ tests/scripts/client.connect-bad-stream.json | 26 ++++++++++ .../client.connect-handshake-failure.json | 51 +++++++++++++++++++ .../client.connect-persistent-failure.json | 34 +++++++++++++ 7 files changed, 149 insertions(+), 20 deletions(-) create mode 100644 tests/scripts/client.connect-bad-stream.json create mode 100644 tests/scripts/client.connect-handshake-failure.json create mode 100644 tests/scripts/client.connect-persistent-failure.json diff --git a/lib/Base.php b/lib/Base.php index 6e48a4b..c9209e4 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -165,18 +165,20 @@ public function receive() $this->connect(); } - do { + while (true) { $message = $this->connection->pullMessage(); $opcode = $message->getOpcode(); - if (in_array($opcode, $filter)) { $this->last_opcode = $opcode; - return $return_obj ? $message : $message->getContent(); + $return = $return_obj ? $message : $message->getContent(); + break; } elseif ($opcode == 'close') { $this->last_opcode = null; - return $return_obj ? $message : null; + $return = $return_obj ? $message : null; + break; } - } while (true); + } + return $return; } /** diff --git a/lib/Client.php b/lib/Client.php index 6a81522..fa1742a 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -126,7 +126,7 @@ protected function connect(): void $this->connection->setLogger($this->logger); if (!$this->isConnected()) { - $error = "Could not open socket to \"{$host}:{$port}\": {$errstr} ({$errno}) {$error}."; + $error = "Invalid stream on \"{$host}:{$port}\": {$errstr} ({$errno}) {$error}."; $this->logger->error($error); throw new ConnectionException($error); } diff --git a/lib/Connection.php b/lib/Connection.php index f06a945..c281f1c 100644 --- a/lib/Connection.php +++ b/lib/Connection.php @@ -49,19 +49,6 @@ public function __destruct() } } - /** - * Get string representation of instance - * @return string String representation - */ - public function __toString(): string - { - return sprintf( - "%s(%s)", - get_class($this), - 'closed' - ); - } - public function setOptions(array $options = []): void { $this->options = array_merge($this->options, $options); @@ -98,7 +85,7 @@ public function close(int $status = 1000, string $message = 'ttfn'): void while (true) { $message = $this->pullMessage(); if ($message->getOpcode() == 'close') { - return; + break; } } } diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 9d62e12..9c25872 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -226,6 +226,15 @@ public function testPersistentConnection(): void $this->assertTrue(MockSocket::isEmpty()); } + public function testFailedPersistentConnection(): void + { + MockSocket::initialize('client.connect-persistent-failure', $this); + $client = new Client('ws://localhost:8000/my/mock/path', ['persistent' => true]); + $this->expectException('WebSocket\ConnectionException'); + $this->expectExceptionMessage('Could not resolve stream pointer position'); + $client->send('Connect'); + } + public function testBadScheme(): void { MockSocket::initialize('client.connect', $this); @@ -273,6 +282,26 @@ public function testFailedConnectionWithError(): void $client->send('Connect'); } + public function testBadStreamConnection(): void + { + MockSocket::initialize('client.connect-bad-stream', $this); + $client = new Client('ws://localhost:8000/my/mock/path'); + $this->expectException('WebSocket\ConnectionException'); + $this->expectExceptionCode(0); + $this->expectExceptionMessage('Invalid stream on "localhost:8000"'); + $client->send('Connect'); + } + + public function testHandshakeFailure(): void + { + MockSocket::initialize('client.connect-handshake-failure', $this); + $client = new Client('ws://localhost:8000/my/mock/path'); + $this->expectException('WebSocket\ConnectionException'); + $this->expectExceptionCode(0); + $this->expectExceptionMessage('Could not read from stream'); + $client->send('Connect'); + } + public function testInvalidUpgrade(): void { MockSocket::initialize('client.connect-invalid-upgrade', $this); diff --git a/tests/scripts/client.connect-bad-stream.json b/tests/scripts/client.connect-bad-stream.json new file mode 100644 index 0000000..aecf0fb --- /dev/null +++ b/tests/scripts/client.connect-bad-stream.json @@ -0,0 +1,26 @@ +[ + { + "function": "stream_context_create", + "params": [], + "return": "@mock-stream-context" + }, + { + "function": "stream_socket_client", + "params": [ + "tcp:\/\/localhost:8000", + null, + null, + 5, + 4, + "@mock-stream-context" + ], + "return": "@mock-stream" + }, + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "bad stream" + } +] \ No newline at end of file diff --git a/tests/scripts/client.connect-handshake-failure.json b/tests/scripts/client.connect-handshake-failure.json new file mode 100644 index 0000000..006f75c --- /dev/null +++ b/tests/scripts/client.connect-handshake-failure.json @@ -0,0 +1,51 @@ +[ + { + "function": "stream_context_create", + "params": [], + "return": "@mock-stream-context" + }, + { + "function": "stream_socket_client", + "params": [ + "tcp:\/\/localhost:8000", + null, + null, + 5, + 4, + "@mock-stream-context" + ], + "return": "@mock-stream" + }, + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "stream" + }, + { + "function": "stream_set_timeout", + "params": [ + "@mock-stream", + 5 + ], + "return": true + }, + { + "function": "fwrite", + "params": [ + "@mock-stream" + ], + "return-op": "key-save", + "return": 199 + }, + { + "function": "stream_get_line", + "params": [ + "@mock-stream", + 1024, + "\r\n\r\n" + ], + "return": false + } +] \ No newline at end of file diff --git a/tests/scripts/client.connect-persistent-failure.json b/tests/scripts/client.connect-persistent-failure.json new file mode 100644 index 0000000..337377d --- /dev/null +++ b/tests/scripts/client.connect-persistent-failure.json @@ -0,0 +1,34 @@ +[ + { + "function": "stream_context_create", + "params": [], + "return": "@mock-stream-context" + }, + { + "function": "stream_socket_client", + "params": [ + "tcp:\/\/localhost:8000", + null, + null, + 5, + 5, + "@mock-stream-context" + ], + "return": "@mock-stream" + }, + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "persistent stream" + }, + { + "function": "ftell", + "params": [ + "@mock-stream" + ], + "return": false + } +] + From e1507f8632f3aa3057cde24e54f44a2b567ac293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sun, 7 Mar 2021 11:56:05 +0100 Subject: [PATCH 08/21] Server listener --- docs/Client.md | 2 +- docs/Server.md | 2 +- examples/echoserver.php | 93 +++++++++++++++++++++------------------ lib/Base.php | 11 ++++- lib/Connection.php | 60 +++++++++++++++++++++---- lib/Server.php | 97 +++++++++++++++++++++++++++++++++++++---- 6 files changed, 203 insertions(+), 62 deletions(-) diff --git a/docs/Client.md b/docs/Client.md index 9124bf8..7d4062c 100644 --- a/docs/Client.md +++ b/docs/Client.md @@ -23,7 +23,7 @@ WebSocket\Client { public close(int $status = 1000, mixed $message = 'ttfn') : mixed public getName() : string|null - public getPier() : string|null + public getPeer() : string|null public getLastOpcode() : string public getCloseStatus() : int public isConnected() : bool diff --git a/docs/Server.md b/docs/Server.md index 7d01a41..5714e1c 100644 --- a/docs/Server.md +++ b/docs/Server.md @@ -32,7 +32,7 @@ WebSocket\Server { public getHeader(string $header_name) : string|null public getName() : string|null - public getPier() : string|null + public getPeer() : string|null public getLastOpcode() : string public getCloseStatus() : int public isConnected() : bool diff --git a/examples/echoserver.php b/examples/echoserver.php index eb5ebef..64804f5 100644 --- a/examples/echoserver.php +++ b/examples/echoserver.php @@ -22,7 +22,7 @@ $options = array_merge([ 'port' => 8000, 'timeout' => 200, - 'filter' => ['text', 'binary', 'ping', 'pong'], + 'filter' => ['text', 'binary', 'ping', 'pong', 'close'], ], getopt('', ['port:', 'timeout:', 'debug'])); // If debug mode and logger is available @@ -42,46 +42,53 @@ echo "> Listening to port {$server->getPort()}\n"; -// Force quit to close server -while (true) { - try { - while ($server->accept()) { - echo "> Accepted on port {$server->getPort()}\n"; - while (true) { - $message = $server->receive(); - $opcode = $server->getLastOpcode(); - if (is_null($message)) { - echo "> Closing connection\n"; - continue 2; - } - echo "> Got '{$message}' [opcode: {$opcode}]\n"; - if (in_array($opcode, ['ping', 'pong'])) { - $server->send($message); - continue; - } - // Allow certain string to trigger server action - switch ($message) { - case 'exit': - echo "> Client told me to quit. Bye bye.\n"; - $server->close(); - echo "> Close status: {$server->getCloseStatus()}\n"; - exit; - case 'headers': - $server->text(implode("\r\n", $server->getRequest())); - break; - case 'ping': - $server->ping($message); - break; - case 'auth': - $auth = $server->getHeader('Authorization'); - $server->text("{$auth} - {$message}"); - break; - default: - $server->text($message); - } - } - } - } catch (ConnectionException $e) { - echo "> ERROR: {$e->getMessage()}\n"; +$server->listen(function ($message, $connection) use ($server) { + $content = $message->getContent(); + $opcode = $message->getOpcode(); + $peer = $connection ? $connection->getPeer() : '(closed)'; + echo "> Got '{$content}' [opcode: {$opcode}, peer: {$peer}]\n"; + + // Connection closed, can't respond + if (!$connection) { + return null; // Continue listening } -} + + if (in_array($opcode, ['ping', 'pong'])) { + $connection->text($content); + echo "< Sent '{$content}' [opcode: text, peer: {$peer}]\n"; + return null; // Continue listening + } + + // Allow certain string to trigger server action + switch ($content) { + case 'auth': + $auth = "{$server->getHeader('Authorization')} - {$content}"; + $connection->text($auth); + echo "< Sent '{$auth}' [opcode: text, peer: {$peer}]\n"; + break; + case 'close': + $connection->close(1000, $content); + echo "< Sent '{$content}' [opcode: close, peer: {$peer}]\n"; + break; + case 'exit': + echo "> Client told me to quit. Bye bye.\n"; + $server->close(); + return true; // Stop listener + case 'headers': + $headers = trim(implode("\r\n", $server->getRequest())); + $connection->text($headers); + echo "< Sent '{$headers}' [opcode: text, peer: {$peer}]\n"; + break; + case 'ping': + $connection->ping($content); + echo "< Sent '{$content}' [opcode: ping, peer: {$peer}]\n"; + break; + case 'pong': + $connection->pong($content); + echo "< Sent '{$content}' [opcode: pong, peer: {$peer}]\n"; + break; + default: + $connection->text($content); + echo "< Sent '{$content}' [opcode: text, peer: {$peer}]\n"; + } +}); diff --git a/lib/Base.php b/lib/Base.php index c9209e4..1a990b5 100644 --- a/lib/Base.php +++ b/lib/Base.php @@ -137,12 +137,21 @@ public function getName(): ?string /** * Get name of remote socket, or null if not connected * @return string|null + * @deprecated Will be removed in future version, use getPeer() instead */ public function getPier(): ?string { - return $this->isConnected() ? $this->connection->getPier() : null; + return $this->getPeer(); } + /** + * Get name of remote socket, or null if not connected + * @return string|null + */ + public function getPeer(): ?string + { + return $this->isConnected() ? $this->connection->getPeer() : null; + } /** * Get string representation of instance * @return string String representation diff --git a/lib/Connection.php b/lib/Connection.php index c281f1c..162817a 100644 --- a/lib/Connection.php +++ b/lib/Connection.php @@ -26,6 +26,7 @@ class Connection implements LoggerAwareInterface private $stream; private $logger; private $read_buffer; + private $msg_factory; private $options = []; protected $is_closing = false; @@ -40,6 +41,7 @@ public function __construct($stream, array $options = []) $this->stream = $stream; $this->setOptions($options); $this->logger = new NullLogger(); + $this->msg_factory = new Factory(); } public function __destruct() @@ -59,6 +61,46 @@ public function getCloseStatus(): ?int return $this->close_status; } + /** + * Convenience method to send text message + * @param string $payload Content as string + */ + public function text(string $payload): void + { + $message = $this->msg_factory->create('text', $payload); + $this->pushMessage($message); + } + + /** + * Convenience method to send binary message + * @param string $payload Content as binary string + */ + public function binary(string $payload): void + { + $message = $this->msg_factory->create('binary', $payload); + $this->pushMessage($message); + } + + /** + * Convenience method to send ping + * @param string $payload Optional text as string + */ + public function ping(string $payload = ''): void + { + $message = $this->msg_factory->create('ping', $payload); + $this->pushMessage($message); + } + + /** + * Convenience method to send unsolicited pong + * @param string $payload Optional text as string + */ + public function pong(string $payload = ''): void + { + $message = $this->msg_factory->create('pong', $payload); + $this->pushMessage($message); + } + /** * Tell the socket to close. * @@ -75,8 +117,7 @@ public function close(int $status = 1000, string $message = 'ttfn'): void foreach (str_split($status_binstr, 8) as $binstr) { $status_str .= chr(bindec($binstr)); } - $factory = new Factory(); - $message = $factory->create('close', $status_str . $message); + $message = $this->msg_factory->create('close', $status_str . $message); $this->pushMessage($message, true); $this->logger->debug("Closing with status: {$status}."); @@ -144,8 +185,7 @@ public function pullMessage(): Message $this->read_buffer = null; } - $factory = new Factory(); - $message = $factory->create($payload_opcode, $payload); + $message = $this->msg_factory->create($payload_opcode, $payload); $this->logger->info("[connection] Pulled {$message}", [ 'opcode' => $payload_opcode, @@ -277,13 +317,12 @@ public function autoRespond(array $frame) { list ($final, $payload, $opcode, $masked) = $frame; $payload_length = strlen($payload); - $factory = new Factory(); switch ($opcode) { case 'ping': // If we received a ping, respond with a pong $this->logger->debug("[connection] Received 'ping', sending 'pong'."); - $message = $factory->create('pong', $payload); + $message = $this->msg_factory->create('pong', $payload); $this->pushMessage($message, $masked); return [$final, $payload, $opcode, $masked]; case 'close': @@ -303,7 +342,7 @@ public function autoRespond(array $frame) $this->logger->debug("[connection] Received 'close', status: {$status}."); if (!$this->is_closing) { $ack = "{$status_bin}Close acknowledged: {$status}"; - $message = $factory->create('close', $ack); + $message = $this->msg_factory->create('close', $ack); $this->pushMessage($message, $masked); } else { $this->is_closing = false; // A close response, all done. @@ -318,6 +357,11 @@ public function autoRespond(array $frame) /* ---------- Stream I/O methods ------------------------------------------------- */ + public function getStream() + { + return $this->isConnected() ? $this->stream : null; + } + /** * Close connection stream. * @return bool @@ -359,7 +403,7 @@ public function getName(): ?string * Get name of remote socket, or null if not connected. * @return string|null */ - public function getPier(): ?string + public function getPeer(): ?string { return stream_socket_get_name($this->stream, true); } diff --git a/lib/Server.php b/lib/Server.php index 3089a2b..509d07d 100644 --- a/lib/Server.php +++ b/lib/Server.php @@ -9,6 +9,9 @@ namespace WebSocket; +use Closure; +use Throwable; + class Server extends Base { // Default options @@ -26,6 +29,7 @@ class Server extends Base protected $listening; protected $request; protected $request_path; + private $connectors = []; /** * @param array $options @@ -101,9 +105,86 @@ public function accept(): bool return (bool)$this->listening; } - protected function connect(): void + /** + * Set server to listen to incoming requests. + * @param Closure A callback function that will be called when server receives message. + * function (Message $message, Connection $connection = null) + * If callback function returns not null value, the listener will halt and return that value. + * Otherwise it will continue listening and propagating messages. + * @return Returns any not null value returned by callback function. + */ + public function listen(Closure $callback) { + while (true) { + // Server accept + if ($stream = @stream_socket_accept($this->listening, 0)) { + $peer = stream_socket_get_name($stream, true); + $this->logger->info("[server] Accepted connection from {$peer}"); + $connection = new Connection($stream, $this->options); + $connection->setLogger($this->logger); + if ($this->options['timeout']) { + $connection->setTimeout($this->options['timeout']); + } + $this->performHandshake($connection); + $this->connectors[$peer] = $connection; + } + // Collect streams to listen to + $streams = array_filter(array_map(function ($connection, $peer) { + $stream = $connection->getStream(); + if (is_null($stream)) { + $this->logger->debug("[server] Remove {$peer} from listener stack"); + unset($this->connectors[$peer]); + } + return $stream; + }, $this->connectors, array_keys($this->connectors))); + + // Handle incoming + if (!empty($streams)) { + $read = $streams; + $write = []; + $except = []; + if (stream_select($read, $write, $except, 0)) { + foreach ($read as $stream) { + try { + $result = null; + $peer = stream_socket_get_name($stream, true); + $connection = $this->connectors[$peer]; + $this->logger->debug("[server] Handling {$peer}"); + $message = $connection->pullMessage(); + if (!$connection->isConnected()) { + unset($this->connectors[$peer]); + $connection = null; + } + // Trigger callback according to filter + $opcode = $message->getOpcode(); + if (in_array($opcode, $this->options['filter'])) { + $this->last_opcode = $opcode; + $result = $callback($message, $connection); + } + // If callback returns not null, exit loop and return that value + if (!is_null($result)) { + return $result; + } + } catch (Throwable $e) { + $this->logger->error("[server] Error occured on {$peer}; {$e->getMessage()}"); + } + } + } + } + } + } + + public function close(int $status = 1000, string $message = 'ttfn'): void + { + parent::close($status, $message); + foreach ($this->connectors as $connection) { + $connection->close($status, $message); + } + } + + protected function connect(): void + { $error = null; set_error_handler(function (int $severity, string $message, string $file, int $line) use (&$error) { $this->logger->warning($message, ['severity' => $severity]); @@ -131,19 +212,19 @@ protected function connect(): void $this->logger->info("Client has connected to port {port}", [ 'port' => $this->port, - 'pier' => $this->connection->getPier(), + 'pier' => $this->connection->getPeer(), ]); - $this->performHandshake(); + $this->performHandshake($this->connection); } - protected function performHandshake(): void + protected function performHandshake(Connection $connection): void { $request = ''; do { - $buffer = $this->connection->getLine(1024, "\r\n"); + $buffer = $connection->getLine(1024, "\r\n"); $request .= $buffer . "\n"; - $metadata = $this->connection->getMeta(); - } while (!$this->connection->eof() && $metadata['unread_bytes'] > 0); + $metadata = $connection->getMeta(); + } while (!$connection->eof() && $metadata['unread_bytes'] > 0); if (!preg_match('/GET (.*) HTTP\//mUi', $request, $matches)) { $error = "No GET in request: {$request}"; @@ -174,7 +255,7 @@ protected function performHandshake(): void . "Sec-WebSocket-Accept: $response_key\r\n" . "\r\n"; - $this->connection->write($header); + $connection->write($header); $this->logger->debug("Handshake on {$get_uri}"); } } From 5b55be7cad53dee5049de4ac8c26f662e6df8ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sun, 7 Mar 2021 15:28:23 +0100 Subject: [PATCH 09/21] Cleaning up Server --- README.md | 25 +++--- docs/Changelog.md | 10 +++ examples/echoserver.php | 14 ++-- lib/Server.php | 176 ++++++++++++++++++++++++++++++---------- 4 files changed, 162 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 4e9c233..ec07fd0 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ It does not include convenience operations such as listeners and implicit error ## Documentation -- [Client](docs/Client.md) -- [Server](docs/Server.md) -- [Message](docs/Message.md) +- [Client overwiew](docs/Client.md) +- [Server overview](docs/Server.md) +- [Classes](docs/Classes/Classes.md) - [Examples](docs/Examples.md) - [Changelog](docs/Changelog.md) - [Contributing](docs/Contributing.md) @@ -25,8 +25,8 @@ composer require textalk/websocket ``` * 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`. +* For PHP `7.1` support use version [`1.4`](https://github.com/Textalk/websocket-php/tree/1.4.0). +* For PHP `^5.4` and `7.0` support use version [`1.3`](https://github.com/Textalk/websocket-php/tree/1.3.0). ## Client @@ -42,18 +42,17 @@ $client->close(); ## Server -The library contains a rudimentary single stream/single thread [server](docs/Server.md). +The library contains a websocket [server](docs/Server.md). It internally supports Upgrade handshake and implicit close and ping/pong operations. - -Note that it does **not** support threading or automatic association ot continuous client requests. -If you require this kind of server behavior, you need to build it on top of provided server implementation. +Preferred operation is using the listener function, but optional operations exist. ```php $server = new WebSocket\Server(); -$server->accept(); -$message = $server->receive(); -$server->text($message); -$server->close(); +$server->listen(function ($message, $connection = null) { + echo "Got {$message->getContent()}\n"; + if (!$connection) return; // Current connection is closed + $connection->text('Sending message to client'); +}); ``` ### License and Contributors diff --git a/docs/Changelog.md b/docs/Changelog.md index 5d49a03..40eecc1 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -2,6 +2,16 @@ # Websocket: Changelog +## `v1.6` + + > PHP version `^7.2` + +### `1.6.0` + + * Listener functions (@sirn-se) + * Multi connection server (@sirn-se) + * Major refactoring, using Connections (@sirn-se) + ## `v1.5` > PHP version `^7.2` diff --git a/examples/echoserver.php b/examples/echoserver.php index 64804f5..5ed8451 100644 --- a/examples/echoserver.php +++ b/examples/echoserver.php @@ -32,7 +32,7 @@ echo "> Using logger\n"; } -// Setting timeout to 200 seconds to make time for all tests and manual runs. +// Initiate server try { $server = new Server($options); } catch (ConnectionException $e) { @@ -42,7 +42,7 @@ echo "> Listening to port {$server->getPort()}\n"; -$server->listen(function ($message, $connection) use ($server) { +$server->listen(function ($message, $connection = null) use ($server) { $content = $message->getContent(); $opcode = $message->getOpcode(); $peer = $connection ? $connection->getPeer() : '(closed)'; @@ -50,13 +50,13 @@ // Connection closed, can't respond if (!$connection) { - return null; // Continue listening + return; // Continue listening } if (in_array($opcode, ['ping', 'pong'])) { $connection->text($content); echo "< Sent '{$content}' [opcode: text, peer: {$peer}]\n"; - return null; // Continue listening + return; // Continue listening } // Allow certain string to trigger server action @@ -71,7 +71,7 @@ echo "< Sent '{$content}' [opcode: close, peer: {$peer}]\n"; break; case 'exit': - echo "> Client told me to quit. Bye bye.\n"; + echo "> Client told me to quit.\n"; $server->close(); return true; // Stop listener case 'headers': @@ -87,6 +87,10 @@ $connection->pong($content); echo "< Sent '{$content}' [opcode: pong, peer: {$peer}]\n"; break; + case 'stop': + $server->stop(); + echo "> Client told me to stop listening.\n"; + break; default: $connection->text($content); echo "< Sent '{$content}' [opcode: text, peer: {$peer}]\n"; diff --git a/lib/Server.php b/lib/Server.php index 509d07d..3b85b93 100644 --- a/lib/Server.php +++ b/lib/Server.php @@ -10,6 +10,7 @@ namespace WebSocket; use Closure; +use Psr\Log\NullLogger; use Throwable; class Server extends Base @@ -24,23 +25,31 @@ class Server extends Base 'timeout' => null, ]; - protected $addr; protected $port; protected $listening; protected $request; protected $request_path; - private $connectors = []; + private $connections = []; + private $listen = false; + + + /* ---------- Construct & Destruct ----------------------------------------------- */ /** * @param array $options * Associative array containing: - * - timeout: Set the socket timeout in seconds. + * - filter: Array of opcodes to handle. Default: ['text', 'binary']. * - fragment_size: Set framgemnt size. Default: 4096 - * - port: Chose port for listening. Default 8000. + * - logger: PSR-3 compatible logger. Default NullLogger. + * - port: Chose port for listening. Default 8000. + * - return_obj: If receive() function return Message instance. Default false. + * - timeout: Set the socket timeout in seconds. */ public function __construct(array $options = []) { - $this->options = array_merge(self::$default_options, $options); + $this->options = array_merge(self::$default_options, [ + 'logger' => new NullLogger(), + ], $options); $this->port = $this->options['port']; $this->setLogger($this->options['logger']); @@ -65,57 +74,40 @@ public function __construct(array $options = []) $this->logger->info("Server listening to port {$this->port}"); } + /** + * Disconnect streams on shutdown. + */ public function __destruct() { +/* if ($this->connection && $this->connection->isConnected()) { $this->connection->disconnect(); } $this->connection = null; - } - - public function getPort(): int - { - return $this->port; - } - - public function getPath(): string - { - return $this->request_path; - } - - public function getRequest(): array - { - return $this->request; - } - - public function getHeader($header): ?string - { - foreach ($this->request as $row) { - if (stripos($row, $header) !== false) { - list($headername, $headervalue) = explode(":", $row); - return trim($headervalue); +*/ + foreach ($this->connections as $connection) { + if ($connection->isConnected()) { + $connection->disconnect(); } } - return null; + $this->connections = []; } - public function accept(): bool - { - $this->connection = null; - return (bool)$this->listening; - } + + /* ---------- Server operations -------------------------------------------------- */ /** * Set server to listen to incoming requests. * @param Closure A callback function that will be called when server receives message. * function (Message $message, Connection $connection = null) - * If callback function returns not null value, the listener will halt and return that value. + * If callback function returns non-null value, the listener will halt and return that value. * Otherwise it will continue listening and propagating messages. - * @return Returns any not null value returned by callback function. + * @return mixed Returns any non-null value returned by callback function. */ public function listen(Closure $callback) { - while (true) { + $this->listen = true; + while ($this->listen) { // Server accept if ($stream = @stream_socket_accept($this->listening, 0)) { $peer = stream_socket_get_name($stream, true); @@ -126,7 +118,7 @@ public function listen(Closure $callback) $connection->setTimeout($this->options['timeout']); } $this->performHandshake($connection); - $this->connectors[$peer] = $connection; + $this->connections[$peer] = $connection; } // Collect streams to listen to @@ -134,10 +126,10 @@ public function listen(Closure $callback) $stream = $connection->getStream(); if (is_null($stream)) { $this->logger->debug("[server] Remove {$peer} from listener stack"); - unset($this->connectors[$peer]); + unset($this->connections[$peer]); } return $stream; - }, $this->connectors, array_keys($this->connectors))); + }, $this->connections, array_keys($this->connections))); // Handle incoming if (!empty($streams)) { @@ -149,11 +141,15 @@ public function listen(Closure $callback) try { $result = null; $peer = stream_socket_get_name($stream, true); - $connection = $this->connectors[$peer]; + if (empty($peer)) { + $this->logger->warning("[server] Got detached stream '{$peer}'"); + continue; + } + $connection = $this->connections[$peer]; $this->logger->debug("[server] Handling {$peer}"); $message = $connection->pullMessage(); if (!$connection->isConnected()) { - unset($this->connectors[$peer]); + unset($this->connections[$peer]); $connection = null; } // Trigger callback according to filter @@ -175,14 +171,102 @@ public function listen(Closure $callback) } } + /** + * Tell server to stop listening to incoming requests. + * Active connections are still available when restarting listening. + */ + public function stop(): void + { + $this->listen = false; + } + + /** + * Accept an incoming request. + * Note that this operation will block accepting additional requests. + * @return bool True if listening + * @deprecated Will be removed in future version + */ + public function accept(): bool + { + $this->connection = null; + return (bool)$this->listening; + } + + + /* ---------- Server option functions -------------------------------------------- */ + + /** + * Get current port. + * @return int port + */ + public function getPort(): int + { + return $this->port; + } + + // Inherited from Base: + // - setLogger + // - setTimeout + // - setFragmentSize + // - getFragmentSize + + + /* ---------- Connection broadcast operations ------------------------------------ */ + + /** + * Close all connections. + * @param int Close status, default: 1000 + * @param string Close message, default: 'ttfn' + */ public function close(int $status = 1000, string $message = 'ttfn'): void { - parent::close($status, $message); - foreach ($this->connectors as $connection) { - $connection->close($status, $message); + foreach ($this->connections as $connection) { + if ($connection->isConnected()) { + $connection->close($status, $message); + } + } + } + + // Inherited from Base: + // - receive + // - send + // - text, binary, ping, pong + + + /* ---------- Connection functions (all deprecated) ------------------------------ */ + + public function getPath(): string + { + return $this->request_path; + } + + public function getRequest(): array + { + return $this->request; + } + + public function getHeader($header): ?string + { + foreach ($this->request as $row) { + if (stripos($row, $header) !== false) { + list($headername, $headervalue) = explode(":", $row); + return trim($headervalue); + } } + return null; } + // Inherited from Base: + // - getLastOpcode + // - getCloseStatus + // - isConnected + // - disconnect + // - getName, getPeer, getPier + + + /* ---------- Helper functions --------------------------------------------------- */ + + // Connect when read/write operation is performed. protected function connect(): void { $error = null; @@ -215,8 +299,10 @@ protected function connect(): void 'pier' => $this->connection->getPeer(), ]); $this->performHandshake($this->connection); + $this->connections = ['*' => $this->connection]; } + // Perform upgrade handshake on new connections. protected function performHandshake(Connection $connection): void { $request = ''; From 40e4d89fd7907e4cf79cc1db7015f5d1b33ce97c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 13 Mar 2021 12:53:17 +0100 Subject: [PATCH 10/21] Separation complete --- lib/Base.php | 216 ------------------------------ lib/Client.php | 250 ++++++++++++++++++++++++++++++++++- lib/OpcodeTrait.php | 22 ++++ lib/Server.php | 313 ++++++++++++++++++++++++++++++++++++-------- 4 files changed, 527 insertions(+), 274 deletions(-) delete mode 100644 lib/Base.php create mode 100644 lib/OpcodeTrait.php diff --git a/lib/Base.php b/lib/Base.php deleted file mode 100644 index 1a990b5..0000000 --- a/lib/Base.php +++ /dev/null @@ -1,216 +0,0 @@ - 0, - 'text' => 1, - 'binary' => 2, - 'close' => 8, - 'ping' => 9, - 'pong' => 10, - ]; - - public function getLastOpcode(): ?string - { - return $this->last_opcode; - } - - public function getCloseStatus(): ?int - { - return $this->connection ? $this->connection->getCloseStatus() : null; - } - - public function isConnected(): bool - { - return $this->connection && $this->connection->isConnected(); - } - - public function setTimeout(int $timeout): void - { - $this->options['timeout'] = $timeout; - if ($this->isConnected()) { - $this->connection->setTimeout($timeout); - $this->connection->setOptions($this->options); - } - } - - public function setFragmentSize(int $fragment_size): self - { - $this->options['fragment_size'] = $fragment_size; - if ($this->connection) { - $this->connection->setOptions($this->options); - } - return $this; - } - - public function getFragmentSize(): int - { - return $this->options['fragment_size']; - } - - public function setLogger(LoggerInterface $logger = null): void - { - $this->logger = $logger ?: new NullLogger(); - } - - public function send(string $payload, string $opcode = 'text', bool $masked = true): void - { - if (!$this->isConnected()) { - $this->connect(); - } - - if (!in_array($opcode, array_keys(self::$opcodes))) { - $warning = "Bad opcode '{$opcode}'. Try 'text' or 'binary'."; - $this->logger->warning($warning); - throw new BadOpcodeException($warning); - } - - $factory = new Factory(); - $message = $factory->create($opcode, $payload); - $this->connection->pushMessage($message, $masked); - } - - /** - * 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'); - } - - /** - * Get name of local socket, or null if not connected - * @return string|null - */ - public function getName(): ?string - { - return $this->isConnected() ? $this->connection->getName() : null; - } - - /** - * Get name of remote socket, or null if not connected - * @return string|null - * @deprecated Will be removed in future version, use getPeer() instead - */ - public function getPier(): ?string - { - return $this->getPeer(); - } - - /** - * Get name of remote socket, or null if not connected - * @return string|null - */ - public function getPeer(): ?string - { - return $this->isConnected() ? $this->connection->getPeer() : null; - } - /** - * Get string representation of instance - * @return string String representation - */ - public function __toString(): string - { - return sprintf( - "%s(%s)", - get_class($this), - $this->getName() ?: 'closed' - ); - } - - public function receive() - { - $filter = $this->options['filter']; - $return_obj = $this->options['return_obj']; - - if (!$this->isConnected()) { - $this->connect(); - } - - while (true) { - $message = $this->connection->pullMessage(); - $opcode = $message->getOpcode(); - if (in_array($opcode, $filter)) { - $this->last_opcode = $opcode; - $return = $return_obj ? $message : $message->getContent(); - break; - } elseif ($opcode == 'close') { - $this->last_opcode = null; - $return = $return_obj ? $message : null; - break; - } - } - return $return; - } - - /** - * Tell the socket to close. - * - * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4 - * @param string $message A closing message, max 125 bytes. - */ - public function close(int $status = 1000, string $message = 'ttfn'): void - { - if (!$this->isConnected()) { - return; - } - $this->connection->close($status, $message); - } - - /** - * Disconnect from client/server. - */ - public function disconnect(): void - { - if ($this->isConnected()) { - $this->connection->disconnect(); - } - } -} diff --git a/lib/Client.php b/lib/Client.php index fa1742a..687f811 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -9,8 +9,14 @@ namespace WebSocket; -class Client extends Base +use Psr\Log\{LoggerAwareInterface, LoggerAwareTrait, LoggerInterface, NullLogger}; +use WebSocket\Message\Factory; + +class Client implements LoggerAwareInterface { + use LoggerAwareTrait; // provides setLogger(LoggerInterface $logger) + use OpcodeTrait; + // Default options protected static $default_options = [ 'context' => null, @@ -24,7 +30,13 @@ class Client extends Base 'timeout' => 5, ]; - protected $socket_uri; + private $socket_uri; + private $connection; + private $options = []; + private $last_opcode = null; + + + /* ---------- Magic methods ------------------------------------------------------ */ /** * @param string $uri A ws/wss-URI @@ -37,11 +49,242 @@ class Client extends Base */ public function __construct(string $uri, array $options = []) { - $this->options = array_merge(self::$default_options, $options); $this->socket_uri = $uri; + $this->options = array_merge(self::$default_options, [ + 'logger' => new NullLogger(), + ], $options); $this->setLogger($this->options['logger']); } + /** + * Get string representation of instance. + * @return string String representation. + */ + public function __toString(): string + { + return sprintf( + "%s(%s)", + get_class($this), + $this->getName() ?: 'closed' + ); + } + + + /* ---------- Client option functions -------------------------------------------- */ + + /** + * Set timeout. + * @param int $timeout Timeout in seconds. + */ + public function setTimeout(int $timeout): void + { + $this->options['timeout'] = $timeout; + if (!$this->isConnected()) { + return; + } + $this->connection->setTimeout($timeout); + $this->connection->setOptions($this->options); + } + + /** + * Set fragmentation size. + * @param int $fragment_size Fragment size in bytes. + * @return self. + */ + public function setFragmentSize(int $fragment_size): self + { + $this->options['fragment_size'] = $fragment_size; + $this->connection->setOptions($this->options); + return $this; + } + + /** + * Get fragmentation size. + * @return int $fragment_size Fragment size in bytes. + */ + public function getFragmentSize(): int + { + return $this->options['fragment_size']; + } + + + /* ---------- Connection operations ---------------------------------------------- */ + + /** + * Send text message. + * @param string $payload Content as string. + */ + public function text(string $payload): void + { + $this->send($payload); + } + + /** + * Send binary message. + * @param string $payload Content as binary string. + */ + public function binary(string $payload): void + { + $this->send($payload, 'binary'); + } + + /** + * Send ping. + * @param string $payload Optional text as string. + */ + public function ping(string $payload = ''): void + { + $this->send($payload, 'ping'); + } + + /** + * Send unsolicited pong. + * @param string $payload Optional text as string. + */ + public function pong(string $payload = ''): void + { + $this->send($payload, 'pong'); + } + + /** + * Send message. + * @param string $payload Message to send. + * @param string $opcode Opcode to use, default: 'text'. + * @param bool $masked If message should be masked default: true. + */ + public function send(string $payload, string $opcode = 'text', bool $masked = true): void + { + if (!$this->isConnected()) { + $this->connect(); + } + + if (!in_array($opcode, array_keys(self::$opcodes))) { + $warning = "Bad opcode '{$opcode}'. Try 'text' or 'binary'."; + $this->logger->warning($warning); + throw new BadOpcodeException($warning); + } + + $factory = new Factory(); + $message = $factory->create($opcode, $payload); + $this->connection->pushMessage($message, $masked); + } + + /** + * Tell the socket to close. + * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4 + * @param string $message A closing message, max 125 bytes. + */ + public function close(int $status = 1000, string $message = 'ttfn'): void + { + if (!$this->isConnected()) { + return; + } + $this->connection->close($status, $message); + } + + /** + * Disconnect from server. + */ + public function disconnect(): void + { + if ($this->isConnected()) { + $this->connection->disconnect(); + } + } + + /** + * Receive message. + * Note that this operation will block reading. + * @return mixed Message, text or null depending on settings. + * @deprecated Will be removed in future version. Use listen() instead. + */ + public function receive() + { + $filter = $this->options['filter']; + $return_obj = $this->options['return_obj']; + + if (!$this->isConnected()) { + $this->connect(); + } + + while (true) { + $message = $this->connection->pullMessage(); + $opcode = $message->getOpcode(); + if (in_array($opcode, $filter)) { + $this->last_opcode = $opcode; + $return = $return_obj ? $message : $message->getContent(); + break; + } elseif ($opcode == 'close') { + $this->last_opcode = null; + $return = $return_obj ? $message : null; + break; + } + } + return $return; + } + + + /* ---------- Connection functions ----------------------------------------------- */ + + /** + * Get last received opcode. + * @return string|null Opcode. + * @deprecated Will be removed in future version. Get opcode from Message instead. + */ + public function getLastOpcode(): ?string + { + return $this->last_opcode; + } + + /** + * Get close status on connection. + * @return int|null Close status. + */ + public function getCloseStatus(): ?int + { + return $this->connection ? $this->connection->getCloseStatus() : null; + } + + /** + * If Client has active connection. + * @return bool True if active connection. + */ + public function isConnected(): bool + { + return $this->connection && $this->connection->isConnected(); + } + + /** + * Get name of local socket, or null if not connected. + * @return string|null + */ + public function getName(): ?string + { + return $this->isConnected() ? $this->connection->getName() : null; + } + + /** + * Get name of remote socket, or null if not connected. + * @return string|null + */ + public function getPeer(): ?string + { + return $this->isConnected() ? $this->connection->getPeer() : null; + } + + /** + * Get name of remote socket, or null if not connected. + * @return string|null + * @deprecated Will be removed in future version, use getPeer() instead. + */ + public function getPier(): ?string + { + return $this->getPeer(); + } + + + /* ---------- Helper functions --------------------------------------------------- */ + /** * Perform WebSocket handshake */ @@ -207,7 +450,6 @@ function ($key, $value) { /** * Generate a random string for WebSocket key. - * * @return string Random string */ protected static function generateKey(): string diff --git a/lib/OpcodeTrait.php b/lib/OpcodeTrait.php new file mode 100644 index 0000000..4453660 --- /dev/null +++ b/lib/OpcodeTrait.php @@ -0,0 +1,22 @@ + 0, + 'text' => 1, + 'binary' => 2, + 'close' => 8, + 'ping' => 9, + 'pong' => 10, + ]; +} diff --git a/lib/Server.php b/lib/Server.php index 3b85b93..6b889f6 100644 --- a/lib/Server.php +++ b/lib/Server.php @@ -10,11 +10,15 @@ namespace WebSocket; use Closure; -use Psr\Log\NullLogger; +use Psr\Log\{LoggerAwareInterface, LoggerAwareTrait, LoggerInterface, NullLogger}; use Throwable; +use WebSocket\Message\Factory; -class Server extends Base +class Server implements LoggerAwareInterface { + use LoggerAwareTrait; // provides setLogger(LoggerInterface $logger) + use OpcodeTrait; + // Default options protected static $default_options = [ 'filter' => ['text', 'binary'], @@ -25,15 +29,17 @@ class Server extends Base 'timeout' => null, ]; - protected $port; - protected $listening; - protected $request; - protected $request_path; + private $port; + private $listening; + private $request; + private $request_path; private $connections = []; + private $options = []; private $listen = false; + private $last_opcode; - /* ---------- Construct & Destruct ----------------------------------------------- */ + /* ---------- Magic methods ------------------------------------------------------ */ /** * @param array $options @@ -75,22 +81,16 @@ public function __construct(array $options = []) } /** - * Disconnect streams on shutdown. + * Get string representation of instance. + * @return string String representation. */ - public function __destruct() + public function __toString(): string { -/* - if ($this->connection && $this->connection->isConnected()) { - $this->connection->disconnect(); - } - $this->connection = null; -*/ - foreach ($this->connections as $connection) { - if ($connection->isConnected()) { - $connection->disconnect(); - } - } - $this->connections = []; + return sprintf( + "%s(%s)", + get_class($this), + $this->getName() ?: 'closed' + ); } @@ -98,11 +98,11 @@ public function __destruct() /** * Set server to listen to incoming requests. - * @param Closure A callback function that will be called when server receives message. + * @param Closure $callback A callback function that will be called when server receives message. * function (Message $message, Connection $connection = null) - * If callback function returns non-null value, the listener will halt and return that value. + * If callback function returns non-empty value, the listener will halt and return that value. * Otherwise it will continue listening and propagating messages. - * @return mixed Returns any non-null value returned by callback function. + * @return mixed Returns any non-empty value returned by callback function. */ public function listen(Closure $callback) { @@ -181,14 +181,14 @@ public function stop(): void } /** - * Accept an incoming request. + * Accept a single incoming request. * Note that this operation will block accepting additional requests. - * @return bool True if listening - * @deprecated Will be removed in future version + * @return bool True if listening. + * @deprecated Will be removed in future version. Use listen() instead. */ public function accept(): bool { - $this->connection = null; + $this->disconnect(); return (bool)$this->listening; } @@ -197,26 +197,120 @@ public function accept(): bool /** * Get current port. - * @return int port + * @return int port. */ public function getPort(): int { return $this->port; } - // Inherited from Base: - // - setLogger - // - setTimeout - // - setFragmentSize - // - getFragmentSize + /** + * Set timeout. + * @param int $timeout Timeout in seconds. + */ + public function setTimeout(int $timeout): void + { + $this->options['timeout'] = $timeout; + if (!$this->isConnected()) { + return; + } + foreach ($this->connections as $connection) { + $connection->setTimeout($timeout); + $connection->setOptions($this->options); + } + } + + /** + * Set fragmentation size. + * @param int $fragment_size Fragment size in bytes. + * @return self. + */ + public function setFragmentSize(int $fragment_size): self + { + $this->options['fragment_size'] = $fragment_size; + foreach ($this->connections as $connection) { + $connection->setOptions($this->options); + } + return $this; + } + + /** + * Get fragmentation size. + * @return int $fragment_size Fragment size in bytes. + */ + public function getFragmentSize(): int + { + return $this->options['fragment_size']; + } /* ---------- Connection broadcast operations ------------------------------------ */ + /** + * Broadcast text message to all conenctions. + * @param string $payload Content as string. + */ + public function text(string $payload): void + { + $this->send($payload); + } + + /** + * Broadcast binary message to all conenctions. + * @param string $payload Content as binary string. + */ + public function binary(string $payload): void + { + $this->send($payload, 'binary'); + } + + /** + * Broadcast ping message to all conenctions. + * @param string $payload Optional text as string. + */ + public function ping(string $payload = ''): void + { + $this->send($payload, 'ping'); + } + + /** + * Broadcast pong message to all conenctions. + * @param string $payload Optional text as string. + */ + public function pong(string $payload = ''): void + { + $this->send($payload, 'pong'); + } + + /** + * Send message on all connections. + * @param string $payload Message to send. + * @param string $opcode Opcode to use, default: 'text'. + * @param bool $masked If message should be masked default: true. + */ + public function send(string $payload, string $opcode = 'text', bool $masked = true): void + { + if (!$this->isConnected()) { + $this->connect(); + } + if (!in_array($opcode, array_keys(self::$opcodes))) { + $warning = "Bad opcode '{$opcode}'. Try 'text' or 'binary'."; + $this->logger->warning($warning); + throw new BadOpcodeException($warning); + } + + $factory = new Factory(); + $message = $factory->create($opcode, $payload); + + foreach ($this->connections as $connection) { + $connection->pushMessage($message, $masked); + } + } + /** * Close all connections. - * @param int Close status, default: 1000 - * @param string Close message, default: 'ttfn' + * @param int $status Close status, default: 1000. + * @param string $message Close message, default: 'ttfn'. */ public function close(int $status = 1000, string $message = 'ttfn'): void { @@ -227,24 +321,79 @@ public function close(int $status = 1000, string $message = 'ttfn'): void } } - // Inherited from Base: - // - receive - // - send - // - text, binary, ping, pong + /** + * Disconnect all connections. + */ + public function disconnect(): void + { + foreach ($this->connections as $connection) { + if ($connection->isConnected()) { + $connection->disconnect(); + } + } + $this->connections = []; + } + + /** + * Receive message from single connection. + * Note that this operation will block reading and only read from first available connection. + * @return mixed Message, text or null depending on settings. + * @deprecated Will be removed in future version. Use listen() instead. + */ + public function receive() + { + $filter = $this->options['filter']; + $return_obj = $this->options['return_obj']; + + if (!$this->isConnected()) { + $this->connect(); + } + $connection = current($this->connections); + + while (true) { + $message = $connection->pullMessage(); + $opcode = $message->getOpcode(); + if (in_array($opcode, $filter)) { + $this->last_opcode = $opcode; + $return = $return_obj ? $message : $message->getContent(); + break; + } elseif ($opcode == 'close') { + $this->last_opcode = null; + $return = $return_obj ? $message : null; + break; + } + } + return $return; + } /* ---------- Connection functions (all deprecated) ------------------------------ */ + /** + * Get requested path from single connection. + * @return string Path. + * @deprecated Will be removed in future version. + */ public function getPath(): string { return $this->request_path; } + /** + * Get request from single connection. + * @return array Request. + * @deprecated Will be removed in future version. + */ public function getRequest(): array { return $this->request; } + /** + * Get headers from single connection. + * @return string|null Headers. + * @deprecated Will be removed in future version. + */ public function getHeader($header): ?string { foreach ($this->request as $row) { @@ -256,18 +405,74 @@ public function getHeader($header): ?string return null; } - // Inherited from Base: - // - getLastOpcode - // - getCloseStatus - // - isConnected - // - disconnect - // - getName, getPeer, getPier + /** + * Get last received opcode. + * @return string|null Opcode. + * @deprecated Will be removed in future version. Get opcode from Message instead. + */ + public function getLastOpcode(): ?string + { + return $this->last_opcode; + } + + /** + * Get close status from single connection. + * @return int|null Close status. + * @deprecated Will be removed in future version. Get close status from Connection instead. + */ + public function getCloseStatus(): ?int + { + return $this->connections ? current($this->connections)->getCloseStatus() : null; + } + + /** + * If Server has active connections. + * @return bool True if active connection. + * @deprecated Will be removed in future version. + */ + public function isConnected(): bool + { + foreach ($this->connections as $connection) { + if ($connection->isConnected()) { + return true; + } + } + return false; + } + + /** + * Get name of local socket from single connection. + * @return string|null Name of local socket. + * @deprecated Will be removed in future version. Get name from Connection instead. + */ + public function getName(): ?string + { + return $this->isConnected() ? current($this->connections)->getName() : null; + } + + /** + * Get name of remote socket from single connection. + * @return string|null Name of remote socket. + * @deprecated Will be removed in future version. Get peer from Connection instead. + */ + public function getPeer(): ?string + { + return $this->isConnected() ? current($this->connections)->getPeer() : null; + } + + /** + * @deprecated Will be removed in future version. + */ + public function getPier(): ?string + { + return $this->getPeer(); + } /* ---------- Helper functions --------------------------------------------------- */ // Connect when read/write operation is performed. - protected function connect(): void + private function connect(): void { $error = null; set_error_handler(function (int $severity, string $message, string $file, int $line) use (&$error) { @@ -287,23 +492,23 @@ protected function connect(): void throw new ConnectionException("Server failed to connect. {$error}"); } - $this->connection = new Connection($socket, $this->options); - $this->connection->setLogger($this->logger); + $connection = new Connection($socket, $this->options); + $connection->setLogger($this->logger); if (isset($this->options['timeout'])) { - $this->connection->setTimeout($this->options['timeout']); + $connection->setTimeout($this->options['timeout']); } $this->logger->info("Client has connected to port {port}", [ 'port' => $this->port, - 'pier' => $this->connection->getPeer(), + 'pier' => $connection->getPeer(), ]); - $this->performHandshake($this->connection); - $this->connections = ['*' => $this->connection]; + $this->performHandshake($connection); + $this->connections = ['*' => $connection]; } // Perform upgrade handshake on new connections. - protected function performHandshake(Connection $connection): void + private function performHandshake(Connection $connection): void { $request = ''; do { From c2d77d8ef70269d11dbc6159fecb9490406eaa36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 20 Mar 2021 11:43:53 +0100 Subject: [PATCH 11/21] Documentation --- docs/Classes/Server.md | 313 ++++++++++++++++++++++++++++++++++++++++ docs/Examples.md | 9 +- examples/echoserver.php | 1 + lib/Client.php | 66 +++++++++ lib/Connection.php | 23 +-- lib/Server.php | 11 +- 6 files changed, 397 insertions(+), 26 deletions(-) create mode 100644 docs/Classes/Server.md diff --git a/docs/Classes/Server.md b/docs/Classes/Server.md new file mode 100644 index 0000000..245ede6 --- /dev/null +++ b/docs/Classes/Server.md @@ -0,0 +1,313 @@ +Classes: [Client](Classes/Client.md) • Server + +# Server class + +Websocket Server class. Support multiple connections through the `listen()` method. + +## Class synopsis + +```php +WebSocket\Server implements Psr\Log\LoggerAwareInterface { + + // Magic methods + public __construct(array $options = []) + public __toString() : string + + // Server operations + public listen(Closure $callback) : mixed + public stop(): void + + // Server option functions + public getPort() : int + public setTimeout(int $seconds) : void + public setFragmentSize(int $fragment_size) : self + public getFragmentSize() : int + + // Connection broadcast operations + 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 close(int $status = 1000, mixed $message = 'ttfn') : void + public disconnect() : void + public receive() : mixed + + // Provided by Psr\Log\LoggerAwareTrait + public setLogger(Psr\Log\LoggerInterface $logger) : void + + // Deprecated functions + public accept() : bool + public getPath() : string + public getRequest() : array + public getHeader(string $header_name) : string|null + public getLastOpcode() : string + public getCloseStatus() : int + public isConnected() : bool + public getName() : string|null + public getPeer() : string|null + public getPier() : string|null +} +``` + +## __construct + +Constructor for Websocket Server. + +#### Description + +```php +public function __construct(array $options = []) +``` + +#### Parameters + +###### `options` + +An optional array of parameters. +| Name | Type | | Default | Description | +| --- | --- | --- | --- [ +| `filter` | `array` | `['text', 'binary']` | Array of opcodes to return on receive and listen functions +| `fragment_size` | `int` | `4096` | Maximum payload size +| `logger` | `Psr\Log\LoggerInterface` | `Psr\Log\NullLogger` |A [PSR-3](https://www.php-fig.org/psr/psr-3/) compatible logger +| `port` | `int` | `8000` | The server port to listen to +| `return_obj` | `bool` | `false` | Return a [Message](Classes/Message.md) instance on receive function +| `timeout` | `int` | `5` | Time out in seconds + +#### Return Values + +Returns a new WebSocket\Server instance. + +#### Errors/Exceptions + +Emits [ConnectionException](Classes/ConnectionException.md) on failure. + +#### Examples + +```php + 8080, 'timeout' => 60]); + +?> +``` + + +## __toString + +Get string representation of instance. + +#### Description + +```php +public function __toString() : string +``` + +#### Return Values + +Returns a string to represent current instance. + + +## listen + +Set server to listen to incoming requests. + +#### Description + +```php +public function listen(Closure $callback) : mixed +``` + +#### Parameters + +###### `callback` + +A callback function that is triggered whenever the server receives a message matching the filter. + +The callback takes two parameters; +* The [Message](Classes/Message/Message.md) that has been received +* The [Connection](Classes/Connection.md) the server has receievd on, can be `null` if connection is closed + +If callback function returns non-null value, the listener will halt and return that value. +Otherwise it will continue listening and propagating messages. + +#### Return Values + +Returns any non-null value returned by callback function. + +#### Errors/Exceptions + +Emits [ConnectionException](Classes/ConnectionException.md) on failure. + +#### Examples + +Minimal setup that continuously listens to incoming text and binary messages. +```php +listen(function ($message, $connection) { + echo $message->getContent(); +}); +?> +``` + +Listen to all incoming message types and respond with a text message. +```php + ['text', 'binary', 'ping', 'pong', 'close']]); +$server->listen(function ($message, $connection) { + if (!$connection) { + $connection->text("Confirm " . $message->getOpcode()); + } +}); +?> +``` + +Halt listener and return a value to calling code. +```php + ['text', 'binary', 'ping', 'pong', 'close']]); +$content = $server->listen(function ($message, $connection) { + return $message->getContent(); +}); +echo $content; +?> +``` + +## stop + +Tell server to stop listening to incoming requests. + +#### Description + +```php +public function stop(): void +``` + +#### Examples + +Use stop() in listener. +```php +listen(function ($message, $connection) use ($server) { + echo $message->getContent(); + $server->stop(); + }); + // Do things, listener will be restarted in next loop. +} +?> +``` + +## getPort + +#### Description + +```php +public function getPort(): void +``` + +## setTimeout + +#### Description + +```php +public function setTimeout(int $seconds): void +``` + +## setFragmentSize + +#### Description + +```php +public function setFragmentSize(int $fragment_size): self +``` + +## getFragmentSize + +#### Description + +```php +public function getFragmentSize(): int +``` + +## text + +#### Description + +```php +public function text(string $payload) : void +``` + +## binary + +#### Description + +```php +public function binary(string $payload) : void +``` + +## ping + +#### Description + +```php +public function ping(string $payload = '') : void +``` + +## pong + +#### Description + +```php +public function pong(string $payload = '') : void +``` + +## send + +#### Description + +```php +public function send(mixed $payload, string $opcode = 'text', bool $masked = true) : void +``` + +## close + +#### Description + +```php +public function close(int $status = 1000, mixed $message = 'ttfn') : void +``` + +## disconnect + +#### Description + +```php +public function disconnect() : void +``` + +## receive + +#### Description + +```php +public function receive() : mixed +``` + +## setLogger + +#### Description + +```php +public setLogger(Psr\Log\LoggerInterface $logger) : void +``` diff --git a/docs/Examples.md b/docs/Examples.md index 7dd4e0c..399e0cc 100644 --- a/docs/Examples.md +++ b/docs/Examples.md @@ -65,10 +65,13 @@ php examples/echoserver.php --debug // Use runtime debugging ``` These strings can be sent as message to trigger server to perform actions; -* `exit` - Server will initiate close procedure -* `ping` - Server will send a ping message -* `headers` - Server will respond with all headers provided by client * `auth` - Server will respond with auth header if provided by client +* `close` - Server will close current connection +* `exit` - Server will close all active connections +* `headers` - Server will respond with all headers provided by client +* `ping` - Server will send a ping message +* `pong` - Server will send a pong message +* `stop` - Server will stop listening * For other sent strings, server will respond with the same strings ## The `random` client diff --git a/examples/echoserver.php b/examples/echoserver.php index 5ed8451..3f7db53 100644 --- a/examples/echoserver.php +++ b/examples/echoserver.php @@ -50,6 +50,7 @@ // Connection closed, can't respond if (!$connection) { + echo "> Connection closed\n"; return; // Continue listening } diff --git a/lib/Client.php b/lib/Client.php index 687f811..b2a840f 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -33,6 +33,7 @@ class Client implements LoggerAwareInterface private $socket_uri; private $connection; private $options = []; + private $listen = false; private $last_opcode = null; @@ -70,6 +71,71 @@ public function __toString(): string } + /* ---------- Client operations -------------------------------------------------- */ + + /** + * Set client to listen to incoming requests. + * @param Closure $callback A callback function that will be called when client receives message. + * function (Message $message, Connection $connection = null) + * If callback function returns non-null value, the listener will halt and return that value. + * Otherwise it will continue listening and propagating messages. + * @return mixed Returns any non-null value returned by callback function. + */ + public function listen(Closure $callback) + { + $this->listen = true; + while ($this->listen) { + // Connect + if (!$this->isConnected()) { + $this->connect(); + } + + // Handle incoming + $read = $this->connection->getStream(); + $write = []; + $except = []; + if (stream_select($read, $write, $except, 0)) { + foreach ($read as $stream) { + try { + $result = null; + $peer = stream_socket_get_name($stream, true); + if (empty($peer)) { + $this->logger->warning("[client] Got detached stream '{$peer}'"); + continue; + } + $this->logger->debug("[client] Handling {$peer}"); + $message = $this->connection->pullMessage(); + if (!$this->connection->isConnected()) { + $this->connection = null; + } + // Trigger callback according to filter + $opcode = $message->getOpcode(); + if (in_array($opcode, $this->options['filter'])) { + $this->last_opcode = $opcode; + $result = $callback($message, $this->connection); + } + // If callback returns not null, exit loop and return that value + if (!is_null($result)) { + return $result; + } + } catch (Throwable $e) { + $this->logger->error("[client] Error occured on {$peer}; {$e->getMessage()}"); + } + } + } + } + } + + /** + * Tell client to stop listening to incoming requests. + * Active connections are still available when restarting listening. + */ + public function stop(): void + { + $this->listen = false; + } + + /* ---------- Client option functions -------------------------------------------- */ /** diff --git a/lib/Connection.php b/lib/Connection.php index 162817a..e5f6421 100644 --- a/lib/Connection.php +++ b/lib/Connection.php @@ -9,11 +9,13 @@ namespace WebSocket; -use Psr\Log\{LoggerAwareInterface, LoggerInterface, NullLogger}; +use Psr\Log\{LoggerAwareInterface, LoggerAwareTrait, LoggerInterface, NullLogger}; use WebSocket\Message\{Factory, Message}; class Connection implements LoggerAwareInterface { + use LoggerAwareTrait; + protected static $opcodes = [ 'continuation' => 0, 'text' => 1, @@ -24,7 +26,6 @@ class Connection implements LoggerAwareInterface ]; private $stream; - private $logger; private $read_buffer; private $msg_factory; private $options = []; @@ -40,7 +41,7 @@ public function __construct($stream, array $options = []) { $this->stream = $stream; $this->setOptions($options); - $this->logger = new NullLogger(); + $this->setLogger(new NullLogger()); $this->msg_factory = new Factory(); } @@ -200,7 +201,7 @@ public function pullMessage(): Message /* ---------- Frame I/O methods -------------------------------------------------- */ // Pull frame from stream - public function pullFrame(): array + private function pullFrame(): array { // Read the fragment "header" first, two bytes. $data = $this->read(2); @@ -313,7 +314,7 @@ private function pushFrame(array $frame): void } // Trigger auto response for frame - public function autoRespond(array $frame) + private function autoRespond(array $frame) { list ($final, $payload, $opcode, $masked) = $frame; $payload_length = strlen($payload); @@ -517,18 +518,6 @@ public function write(string $data): void } - /* ---------- PSR-3 Logger implemetation ----------------------------------------- */ - - /** - * Set logger. - * @param LoggerInterface Logger implementation - */ - public function setLogger(LoggerInterface $logger): void - { - $this->logger = $logger; - } - - /* ---------- Internal helper methods -------------------------------------------- */ private function throwException(string $message, int $code = 0): void diff --git a/lib/Server.php b/lib/Server.php index 6b889f6..154458a 100644 --- a/lib/Server.php +++ b/lib/Server.php @@ -100,9 +100,9 @@ public function __toString(): string * Set server to listen to incoming requests. * @param Closure $callback A callback function that will be called when server receives message. * function (Message $message, Connection $connection = null) - * If callback function returns non-empty value, the listener will halt and return that value. + * If callback function returns non-null value, the listener will halt and return that value. * Otherwise it will continue listening and propagating messages. - * @return mixed Returns any non-empty value returned by callback function. + * @return mixed Returns any non-null value returned by callback function. */ public function listen(Closure $callback) { @@ -338,7 +338,6 @@ public function disconnect(): void * Receive message from single connection. * Note that this operation will block reading and only read from first available connection. * @return mixed Message, text or null depending on settings. - * @deprecated Will be removed in future version. Use listen() instead. */ public function receive() { @@ -370,7 +369,7 @@ public function receive() /* ---------- Connection functions (all deprecated) ------------------------------ */ /** - * Get requested path from single connection. + * Get requested path from last connection. * @return string Path. * @deprecated Will be removed in future version. */ @@ -380,7 +379,7 @@ public function getPath(): string } /** - * Get request from single connection. + * Get request from last connection. * @return array Request. * @deprecated Will be removed in future version. */ @@ -390,7 +389,7 @@ public function getRequest(): array } /** - * Get headers from single connection. + * Get headers from last connection. * @return string|null Headers. * @deprecated Will be removed in future version. */ From 22806b0a2e7338d084730ef5ad5b9a86581f5908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 20 Mar 2021 11:55:13 +0100 Subject: [PATCH 12/21] Documentation --- docs/Classes/Client.md | 6 ++++++ docs/Classes/Connection.md | 6 ++++++ docs/Classes/Message.md | 6 ++++++ docs/Classes/Server.md | 28 ++++++++++++++-------------- 4 files changed, 32 insertions(+), 14 deletions(-) create mode 100644 docs/Classes/Client.md create mode 100644 docs/Classes/Connection.md create mode 100644 docs/Classes/Message.md diff --git a/docs/Classes/Client.md b/docs/Classes/Client.md new file mode 100644 index 0000000..1e825d8 --- /dev/null +++ b/docs/Classes/Client.md @@ -0,0 +1,6 @@ +Classes: [Client](Client.md) • Server + +# Client class + +Websocket Client class. + diff --git a/docs/Classes/Connection.md b/docs/Classes/Connection.md new file mode 100644 index 0000000..a1e8bc1 --- /dev/null +++ b/docs/Classes/Connection.md @@ -0,0 +1,6 @@ +Classes: [Client](Client.md) • Server + +# Connection class + +Websocket Connection class. + diff --git a/docs/Classes/Message.md b/docs/Classes/Message.md new file mode 100644 index 0000000..3989647 --- /dev/null +++ b/docs/Classes/Message.md @@ -0,0 +1,6 @@ +Classes: [Client](Client.md) • Server + +# Message class + +Websocket Message class. + diff --git a/docs/Classes/Server.md b/docs/Classes/Server.md index 245ede6..5b27059 100644 --- a/docs/Classes/Server.md +++ b/docs/Classes/Server.md @@ -1,4 +1,4 @@ -Classes: [Client](Classes/Client.md) • Server +Classes: [Client](Client.md) • Server # Server class @@ -65,14 +65,14 @@ public function __construct(array $options = []) ###### `options` An optional array of parameters. -| Name | Type | | Default | Description | -| --- | --- | --- | --- [ -| `filter` | `array` | `['text', 'binary']` | Array of opcodes to return on receive and listen functions -| `fragment_size` | `int` | `4096` | Maximum payload size -| `logger` | `Psr\Log\LoggerInterface` | `Psr\Log\NullLogger` |A [PSR-3](https://www.php-fig.org/psr/psr-3/) compatible logger -| `port` | `int` | `8000` | The server port to listen to -| `return_obj` | `bool` | `false` | Return a [Message](Classes/Message.md) instance on receive function -| `timeout` | `int` | `5` | Time out in seconds +Name | Type | Default | Description +--- | --- | --- | --- +filter` | array | ['text', 'binary'] | Array of opcodes to return on receive and listen functions +fragment_size | int | 4096 | Maximum payload size +logger | Psr\Log\LoggerInterface | Psr\Log\NullLogger |A [PSR-3](https://www.php-fig.org/psr/psr-3/) compatible logger +port | int | 8000 | The server port to listen to +return_obj | bool | false | Return a [Message](Message.md) instance on receive function +timeout | int | 5 | Time out in seconds #### Return Values @@ -80,7 +80,7 @@ Returns a new WebSocket\Server instance. #### Errors/Exceptions -Emits [ConnectionException](Classes/ConnectionException.md) on failure. +Emits [ConnectionException](ConnectionException.md) on failure. #### Examples @@ -129,8 +129,8 @@ public function listen(Closure $callback) : mixed A callback function that is triggered whenever the server receives a message matching the filter. The callback takes two parameters; -* The [Message](Classes/Message/Message.md) that has been received -* The [Connection](Classes/Connection.md) the server has receievd on, can be `null` if connection is closed +* The [Message](Message/Message.md) that has been received +* The [Connection](Connection.md) the server has receievd on, can be `null` if connection is closed If callback function returns non-null value, the listener will halt and return that value. Otherwise it will continue listening and propagating messages. @@ -141,7 +141,7 @@ Returns any non-null value returned by callback function. #### Errors/Exceptions -Emits [ConnectionException](Classes/ConnectionException.md) on failure. +Emits [ConnectionException](ConnectionException.md) on failure. #### Examples @@ -173,7 +173,7 @@ Halt listener and return a value to calling code. ```php ['text', 'binary', 'ping', 'pong', 'close']]); +$server = new WebSocket\Server(); $content = $server->listen(function ($message, $connection) { return $message->getContent(); }); From b6e2aedbcf49df867219b32a0cf8d02674b5fa7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 17 Apr 2021 15:30:27 +0200 Subject: [PATCH 13/21] Documentation --- docs/Classes/Server.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Classes/Server.md b/docs/Classes/Server.md index 5b27059..a1f9e33 100644 --- a/docs/Classes/Server.md +++ b/docs/Classes/Server.md @@ -67,7 +67,7 @@ public function __construct(array $options = []) An optional array of parameters. Name | Type | Default | Description --- | --- | --- | --- -filter` | array | ['text', 'binary'] | Array of opcodes to return on receive and listen functions +filter | array | ["text", "binary"] | Array of opcodes to return on receive and listen functions fragment_size | int | 4096 | Maximum payload size logger | Psr\Log\LoggerInterface | Psr\Log\NullLogger |A [PSR-3](https://www.php-fig.org/psr/psr-3/) compatible logger port | int | 8000 | The server port to listen to From fe0c9dd8381fe1831bf95cbaccc1f9505c33813c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 7 Aug 2021 13:44:30 +0200 Subject: [PATCH 14/21] Coverage --- .github/workflows/acceptance.yml | 15 -------- lib/Client.php | 65 -------------------------------- lib/Server.php | 10 ++--- tests/ClientTest.php | 12 ++++++ tests/README.md | 3 +- tests/ServerTest.php | 14 +++++++ 6 files changed, 32 insertions(+), 87 deletions(-) diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index c8317bb..8a1db76 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -48,21 +48,6 @@ jobs: - name: Test run: make test - test-8-1: - runs-on: ubuntu-latest - name: Test PHP 8.1 - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Set up PHP 8.1 - uses: shivammathur/setup-php@v2 - with: - php-version: '8.1' - - name: Composer - run: make install - - name: Test - run: make test - cs-check: runs-on: ubuntu-latest name: Code standard diff --git a/lib/Client.php b/lib/Client.php index 96f6f18..61c51f3 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -71,71 +71,6 @@ public function __toString(): string } - /* ---------- Client operations -------------------------------------------------- */ - - /** - * Set client to listen to incoming requests. - * @param Closure $callback A callback function that will be called when client receives message. - * function (Message $message, Connection $connection = null) - * If callback function returns non-null value, the listener will halt and return that value. - * Otherwise it will continue listening and propagating messages. - * @return mixed Returns any non-null value returned by callback function. - */ - public function listen(Closure $callback) - { - $this->listen = true; - while ($this->listen) { - // Connect - if (!$this->isConnected()) { - $this->connect(); - } - - // Handle incoming - $read = $this->connection->getStream(); - $write = []; - $except = []; - if (stream_select($read, $write, $except, 0)) { - foreach ($read as $stream) { - try { - $result = null; - $peer = stream_socket_get_name($stream, true); - if (empty($peer)) { - $this->logger->warning("[client] Got detached stream '{$peer}'"); - continue; - } - $this->logger->debug("[client] Handling {$peer}"); - $message = $this->connection->pullMessage(); - if (!$this->connection->isConnected()) { - $this->connection = null; - } - // Trigger callback according to filter - $opcode = $message->getOpcode(); - if (in_array($opcode, $this->options['filter'])) { - $this->last_opcode = $opcode; - $result = $callback($message, $this->connection); - } - // If callback returns not null, exit loop and return that value - if (!is_null($result)) { - return $result; - } - } catch (Throwable $e) { - $this->logger->error("[client] Error occured on {$peer}; {$e->getMessage()}"); - } - } - } - } - } - - /** - * Tell client to stop listening to incoming requests. - * Active connections are still available when restarting listening. - */ - public function stop(): void - { - $this->listen = false; - } - - /* ---------- Client option functions -------------------------------------------- */ /** diff --git a/lib/Server.php b/lib/Server.php index 154458a..3d81733 100644 --- a/lib/Server.php +++ b/lib/Server.php @@ -16,7 +16,7 @@ class Server implements LoggerAwareInterface { - use LoggerAwareTrait; // provides setLogger(LoggerInterface $logger) + use LoggerAwareTrait; // Provides setLogger(LoggerInterface $logger) use OpcodeTrait; // Default options @@ -29,10 +29,10 @@ class Server implements LoggerAwareInterface 'timeout' => null, ]; - private $port; - private $listening; - private $request; - private $request_path; + protected $port; + protected $listening; + protected $request; + protected $request_path; private $connections = []; private $options = []; private $listen = false; diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 8576d23..7404d6e 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -475,4 +475,16 @@ public function testConvenicanceMethods(): void $this->assertEquals('127.0.0.1:8000', $client->getPier()); $this->assertEquals('WebSocket\Client(127.0.0.1:12345)', "{$client}"); } + + public function testUnconnectedClient(): void + { + $client = new Client('ws://localhost:8000/my/mock/path'); + $this->assertFalse($client->isConnected()); + $client->setTimeout(30); + $client->close(); + $this->assertFalse($client->isConnected()); + $this->assertNull($client->getName()); + $this->assertNull($client->getPeer()); + $this->assertNull($client->getCloseStatus()); + } } diff --git a/tests/README.md b/tests/README.md index 86e2836..14f08fa 100644 --- a/tests/README.md +++ b/tests/README.md @@ -14,8 +14,7 @@ make test ## Continuous integration -[Travis](https://travis-ci.org/Textalk/websocket-php) is run on PHP versions -`5.4`, `5.5`, `5.6`, `7.0`, `7.1`, `7.2`, `7.3` and `7.4`. +GitHub Actions are run on PHP versions 7.3, 7.4, 8.0 and 8.1. Code coverage by [Coveralls](https://coveralls.io/github/Textalk/websocket-php). diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 8294236..66a415f 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -445,4 +445,18 @@ public function testConvenicanceMethods(): void $this->assertEquals('WebSocket\Server(127.0.0.1:12345)', "{$server}"); $this->assertTrue(MockSocket::isEmpty()); } + + public function testUnconnectedServer(): void + { + MockSocket::initialize('server.construct', $this); + $server = new Server(); + $this->assertFalse($server->isConnected()); + $server->setTimeout(30); + $server->close(); + $this->assertFalse($server->isConnected()); + $this->assertNull($server->getName()); + $this->assertNull($server->getPeer()); + $this->assertNull($server->getCloseStatus()); + $this->assertTrue(MockSocket::isEmpty()); + } } From ef92a6af5f58751c7880278858fea87e5dce97d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Wed, 26 Oct 2022 21:23:50 +0200 Subject: [PATCH 15/21] Updates --- .github/workflows/acceptance.yml | 49 +++++++++++++++++++++----------- Makefile | 2 +- codestandard.xml | 10 ------- composer.json | 11 +++---- lib/BadOpcodeException.php | 7 +++++ lib/BadUriException.php | 7 +++++ lib/Client.php | 2 +- lib/Connection.php | 15 +++++++--- lib/ConnectionException.php | 7 +++++ lib/Message/Binary.php | 7 +++++ lib/Message/Close.php | 7 +++++ lib/Message/Factory.php | 7 +++++ lib/Message/Message.php | 7 +++++ lib/Message/Ping.php | 7 +++++ lib/Message/Pong.php | 7 +++++ lib/Message/Text.php | 7 +++++ lib/OpcodeTrait.php | 2 +- lib/Server.php | 2 +- lib/TimeoutException.php | 7 +++++ phpunit.xml.dist | 23 +++++++-------- 20 files changed, 141 insertions(+), 52 deletions(-) delete mode 100644 codestandard.xml diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 8a1db76..a55f2a7 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -3,46 +3,61 @@ name: Acceptance on: [push, pull_request] jobs: - test-7-3: + test-7-4: runs-on: ubuntu-latest - name: Test PHP 7.3 + name: Test PHP 7.4 steps: - name: Checkout - uses: actions/checkout@v2 - - name: Set up PHP 7.3 + uses: actions/checkout@v3 + - name: Set up PHP 7.4 uses: shivammathur/setup-php@v2 with: - php-version: '7.3' + php-version: '7.4' - name: Composer run: make install - name: Test run: make test - test-7-4: + test-8-0: runs-on: ubuntu-latest - name: Test PHP 7.4 + name: Test PHP 8.0 steps: - name: Checkout - uses: actions/checkout@v2 - - name: Set up PHP 7.4 + uses: actions/checkout@v3 + - name: Set up PHP 8.0 uses: shivammathur/setup-php@v2 with: - php-version: '7.4' + php-version: '8.0' - name: Composer run: make install - name: Test run: make test - test-8-0: + test-8-1: runs-on: ubuntu-latest - name: Test PHP 8.0 + name: Test PHP 8.1 steps: - name: Checkout - uses: actions/checkout@v2 - - name: Set up PHP 8.0 + uses: actions/checkout@v3 + - name: Set up PHP 8.1 uses: shivammathur/setup-php@v2 with: - php-version: '8.0' + php-version: '8.1' + - name: Composer + run: make install + - name: Test + run: make test + + test-8-2: + runs-on: ubuntu-latest + name: Test PHP 8.2 + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up PHP 8.2 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' - name: Composer run: make install - name: Test @@ -53,7 +68,7 @@ jobs: name: Code standard steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Set up PHP 8.0 uses: shivammathur/setup-php@v2 with: @@ -68,7 +83,7 @@ jobs: name: Code coverage steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Set up PHP 8.0 uses: shivammathur/setup-php@v2 with: diff --git a/Makefile b/Makefile index 930a9ed..54d507e 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ test: composer.lock ./vendor/bin/phpunit cs-check: composer.lock - ./vendor/bin/phpcs --standard=codestandard.xml lib tests examples + ./vendor/bin/phpcs --standard=PSR1,PSR12 --encoding=UTF-8 --report=full --colors lib tests examples coverage: composer.lock build XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-clover build/logs/clover.xml diff --git a/codestandard.xml b/codestandard.xml deleted file mode 100644 index bb1cd26..0000000 --- a/codestandard.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/composer.json b/composer.json index 9bc0dcc..5e5c47d 100644 --- a/composer.json +++ b/composer.json @@ -8,8 +8,7 @@ "name": "Fredrik Liljegren" }, { - "name": "Sören Jensen", - "email": "soren@abicart.se" + "name": "Sören Jensen" } ], "autoload": { @@ -23,11 +22,13 @@ } }, "require": { - "php": "^7.2 | ^8.0", - "psr/log": "^1 | ^2 | ^3" + "php": "^7.4 | ^8.0", + "phrity/net-uri": "^1.0", + "psr/log": "^1.0 | ^2.0 | ^3.0", + "psr/http-message": "^1.0" }, "require-dev": { - "phpunit/phpunit": "^8.0|^9.0", + "phpunit/phpunit": "^9.0", "php-coveralls/php-coveralls": "^2.0", "squizlabs/php_codesniffer": "^3.5" } diff --git a/lib/BadOpcodeException.php b/lib/BadOpcodeException.php index a518715..260a977 100644 --- a/lib/BadOpcodeException.php +++ b/lib/BadOpcodeException.php @@ -1,5 +1,12 @@ stream, true); } diff --git a/lib/ConnectionException.php b/lib/ConnectionException.php index 7e1ecbf..aa1d7f4 100644 --- a/lib/ConnectionException.php +++ b/lib/ConnectionException.php @@ -1,5 +1,12 @@ - - - - - tests - - - - - lib/ - - + + + + lib/ + + + + + tests + + From e5a1ec0bf0afe69a71b76d09b6ea9cb91d13edc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Thu, 27 Oct 2022 12:06:29 +0200 Subject: [PATCH 16/21] PHP 8.2 fix --- lib/Connection.php | 45 ------------------- lib/Message/Message.php | 4 +- phpunit.xml.dist | 11 ++++- tests/ClientTest.php | 7 +++ tests/ServerTest.php | 33 ++++++++++++++ .../server.accept-failed-handshake.json | 32 +++++++++++++ tests/scripts/server.disconnect.json | 24 ++++++++++ 7 files changed, 109 insertions(+), 47 deletions(-) create mode 100644 tests/scripts/server.accept-failed-handshake.json create mode 100644 tests/scripts/server.disconnect.json diff --git a/lib/Connection.php b/lib/Connection.php index 3d66e8f..d5aa48b 100644 --- a/lib/Connection.php +++ b/lib/Connection.php @@ -61,46 +61,6 @@ public function getCloseStatus(): ?int return $this->close_status; } - /** - * Convenience method to send text message - * @param string $payload Content as string - */ - public function text(string $payload): void - { - $message = $this->msg_factory->create('text', $payload); - $this->pushMessage($message); - } - - /** - * Convenience method to send binary message - * @param string $payload Content as binary string - */ - public function binary(string $payload): void - { - $message = $this->msg_factory->create('binary', $payload); - $this->pushMessage($message); - } - - /** - * Convenience method to send ping - * @param string $payload Optional text as string - */ - public function ping(string $payload = ''): void - { - $message = $this->msg_factory->create('ping', $payload); - $this->pushMessage($message); - } - - /** - * Convenience method to send unsolicited pong - * @param string $payload Optional text as string - */ - public function pong(string $payload = ''): void - { - $message = $this->msg_factory->create('pong', $payload); - $this->pushMessage($message); - } - /** * Tell the socket to close. * @@ -357,11 +317,6 @@ private function autoRespond(array $frame) /* ---------- Stream I/O methods ------------------------------------------------- */ - public function getStream() - { - return $this->isConnected() ? $this->stream : null; - } - /** * Close connection stream. * @return bool diff --git a/lib/Message/Message.php b/lib/Message/Message.php index 1bf5a8d..3bdd0ad 100644 --- a/lib/Message/Message.php +++ b/lib/Message/Message.php @@ -61,8 +61,10 @@ public function __toString(): string // Split messages into frames public function getFrames(bool $masked = true, int $framesize = 4096): array { + $frames = []; - foreach (str_split($this->getContent(), $framesize) as $payload) { + $split = str_split($this->getContent(), $framesize) ?: ['']; + foreach ($split as $payload) { $frames[] = [false, $payload, 'continuation', $masked]; } $frames[0][2] = $this->opcode; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 60aa5bb..818e7cb 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,14 @@ - + lib/ diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 0a8690d..c196080 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -512,4 +512,11 @@ public function testUnconnectedClient(): void $this->assertNull($client->getRemoteName()); $this->assertNull($client->getCloseStatus()); } + + public function testDeprecated(): void + { + $client = new Client('ws://localhost:8000/my/mock/path'); + $this->expectDeprecation(); + $this->assertNull($client->getPier()); + } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index f69bf9e..15c5f79 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -458,4 +458,37 @@ public function testUnconnectedServer(): void $this->assertNull($server->getCloseStatus()); $this->assertTrue(MockSocket::isEmpty()); } + + public function testFailedHandshake(): void + { + MockSocket::initialize('server.construct', $this); + $server = new Server(); + $this->assertTrue(MockSocket::isEmpty()); + + MockSocket::initialize('server.accept-failed-handshake', $this); + $server->accept(); + $this->expectException('WebSocket\ConnectionException'); + $this->expectExceptionCode(0); + $this->expectExceptionMessage('Could not read from stream'); + $server->send('Connect'); + $this->assertFalse($server->isConnected()); + $this->assertTrue(MockSocket::isEmpty()); + } + + public function testServerDisconnect(): void + { + MockSocket::initialize('server.construct', $this); + $server = new Server(); + $this->assertTrue(MockSocket::isEmpty()); + MockSocket::initialize('server.accept', $this); + $server->accept(); + $server->send('Connect'); + $this->assertTrue($server->isConnected()); + $this->assertTrue(MockSocket::isEmpty()); + + MockSocket::initialize('server.disconnect', $this); + $server->disconnect(); + $this->assertFalse($server->isConnected()); + $this->assertTrue(MockSocket::isEmpty()); + } } diff --git a/tests/scripts/server.accept-failed-handshake.json b/tests/scripts/server.accept-failed-handshake.json new file mode 100644 index 0000000..4827dad --- /dev/null +++ b/tests/scripts/server.accept-failed-handshake.json @@ -0,0 +1,32 @@ +[ + { + "function": "stream_socket_accept", + "params": [ + "@mock-socket" + ], + "return": "@mock-stream" + }, + { + "function": "stream_socket_get_name", + "params": [ + "@mock-stream" + ], + "return": "127.0.0.1:12345" + }, + { + "function": "stream_get_line", + "params": [ + "@mock-stream", + 1024, + "\r\n" + ], + "return": false + }, + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "" + } +] \ No newline at end of file diff --git a/tests/scripts/server.disconnect.json b/tests/scripts/server.disconnect.json new file mode 100644 index 0000000..7cfd788 --- /dev/null +++ b/tests/scripts/server.disconnect.json @@ -0,0 +1,24 @@ +[ + + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "stream" + }, + { + "function": "fclose", + "params": [ + "@mock-stream" + ], + "return": true + }, + { + "function": "get_resource_type", + "params": [ + "@mock-stream" + ], + "return": "" + } +] \ No newline at end of file From 420d88217d47917b47a5152a650572cfabe54531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Thu, 27 Oct 2022 14:56:36 +0200 Subject: [PATCH 17/21] PHP 8.2 fix --- composer.json | 1 + lib/Client.php | 51 ++++++++++++++++++++++---------------------- lib/Server.php | 35 ++++++++++++++++-------------- phpunit.xml.dist | 11 +--------- tests/ClientTest.php | 12 +++++++++-- tests/ServerTest.php | 17 +++++++++++++++ 6 files changed, 74 insertions(+), 53 deletions(-) diff --git a/composer.json b/composer.json index 5e5c47d..23018ee 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "require": { "php": "^7.4 | ^8.0", "phrity/net-uri": "^1.0", + "phrity/util-errorhandler": "^1.0", "psr/log": "^1.0 | ^2.0 | ^3.0", "psr/http-message": "^1.0" }, diff --git a/lib/Client.php b/lib/Client.php index a64d140..6270b66 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -9,8 +9,10 @@ namespace WebSocket; +use ErrorException; use InvalidArgumentException; use Phrity\Net\Uri; +use Phrity\Util\ErrorHandler; use Psr\Http\Message\UriInterface; use Psr\Log\{ LoggerAwareInterface, @@ -330,36 +332,35 @@ protected function connect(): void $persistent = $this->options['persistent'] === true; $flags = STREAM_CLIENT_CONNECT; $flags = $persistent ? $flags | STREAM_CLIENT_PERSISTENT : $flags; - - $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. - $socket = stream_socket_client( - $host_uri, - $errno, - $errstr, - $this->options['timeout'], - $flags, - $context - ); - - restore_error_handler(); - - if (!$socket) { - $error = "Could not open socket to \"{$host_uri->getAuthority()}\": {$errstr} ({$errno}) {$error}."; - $this->logger->error($error); - throw new ConnectionException($error); + $socket = null; + + try { + $handler = new ErrorHandler(); + $socket = $handler->with(function () use ($host_uri, $flags, $context) { + $error = $errno = $errstr = null; + // Open the socket. + return stream_socket_client( + $host_uri, + $errno, + $errstr, + $this->options['timeout'], + $flags, + $context + ); + }); + if (!$socket) { + throw new ErrorException('No socket'); + } + } catch (ErrorException $e) { + $error = "Could not open socket to \"{$host_uri->getAuthority()}\": {$e->getMessage()} ({$e->getCode()})."; + $this->logger->error($error, ['severity' => $e->getSeverity()]); + throw new ConnectionException($error, 0, [], $e); } $this->connection = new Connection($socket, $this->options); $this->connection->setLogger($this->logger); - if (!$this->isConnected()) { - $error = "Invalid stream on \"{$host_uri->getAuthority()}\": {$errstr} ({$errno}) {$error}."; + $error = "Invalid stream on \"{$host_uri->getAuthority()}\"."; $this->logger->error($error); throw new ConnectionException($error); } diff --git a/lib/Server.php b/lib/Server.php index c2c1da1..1521588 100644 --- a/lib/Server.php +++ b/lib/Server.php @@ -10,6 +10,8 @@ namespace WebSocket; use Closure; +use ErrorException; +use Phrity\Util\ErrorHandler; use Psr\Log\{ LoggerAwareInterface, LoggerAwareTrait, @@ -389,22 +391,23 @@ public function getPier(): ?string // Connect when read/write operation is performed. private function connect(): void { - $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'])) { - $socket = stream_socket_accept($this->listening, $this->options['timeout']); - } else { - $socket = stream_socket_accept($this->listening); - } - - restore_error_handler(); - - if (!$socket) { - throw new ConnectionException("Server failed to connect. {$error}"); + try { + $handler = new ErrorHandler(); + $socket = $handler->with(function () { + if (isset($this->options['timeout'])) { + $socket = stream_socket_accept($this->listening, $this->options['timeout']); + } else { + $socket = stream_socket_accept($this->listening); + } + if (!$socket) { + throw new ErrorException('No socket'); + } + return $socket; + }); + } catch (ErrorException $e) { + $error = "Server failed to connect. {$e->getMessage()}"; + $this->logger->error($error, ['severity' => $e->getSeverity()]); + throw new ConnectionException($error, 0, [], $e); } $connection = new Connection($socket, $this->options); diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 818e7cb..60aa5bb 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,14 +1,5 @@ - + lib/ diff --git a/tests/ClientTest.php b/tests/ClientTest.php index c196080..c1a361a 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -9,7 +9,9 @@ namespace WebSocket; +use ErrorException; use Phrity\Net\Uri; +use Phrity\Util\ErrorHandler; use PHPUnit\Framework\TestCase; class ClientTest extends TestCase @@ -516,7 +518,13 @@ public function testUnconnectedClient(): void public function testDeprecated(): void { $client = new Client('ws://localhost:8000/my/mock/path'); - $this->expectDeprecation(); - $this->assertNull($client->getPier()); + (new ErrorHandler())->with(function () use ($client) { + $this->assertNull($client->getPier()); + }, function (ErrorException $e) { + $this->assertEquals( + 'getPier() is deprecated and will be removed in future version. Use getRemoteName() instead.', + $e->getMessage() + ); + }, E_USER_DEPRECATED); } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 15c5f79..17e9bab 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -9,6 +9,8 @@ namespace WebSocket; +use ErrorException; +use Phrity\Util\ErrorHandler; use PHPUnit\Framework\TestCase; class ServerTest extends TestCase @@ -491,4 +493,19 @@ public function testServerDisconnect(): void $this->assertFalse($server->isConnected()); $this->assertTrue(MockSocket::isEmpty()); } + + public function testDeprecated(): void + { + MockSocket::initialize('server.construct', $this); + $server = new Server(); + $this->assertTrue(MockSocket::isEmpty()); + (new ErrorHandler())->with(function () use ($server) { + $this->assertNull($server->getPier()); + }, function (ErrorException $e) { + $this->assertEquals( + 'getPier() is deprecated and will be removed in future version. Use getRemoteName() instead.', + $e->getMessage() + ); + }, E_USER_DEPRECATED); + } } From 55dea37d22d24af4e4007e1dd1f11a2e298e533b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Thu, 27 Oct 2022 15:10:28 +0200 Subject: [PATCH 18/21] PHP 8.2 fix --- tests/ClientTest.php | 6 +++--- tests/ServerTest.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/ClientTest.php b/tests/ClientTest.php index c1a361a..3c68942 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -518,12 +518,12 @@ public function testUnconnectedClient(): void public function testDeprecated(): void { $client = new Client('ws://localhost:8000/my/mock/path'); - (new ErrorHandler())->with(function () use ($client) { + (new ErrorHandler())->withAll(function () use ($client) { $this->assertNull($client->getPier()); - }, function (ErrorException $e) { + }, function ($exceptions, $result) { $this->assertEquals( 'getPier() is deprecated and will be removed in future version. Use getRemoteName() instead.', - $e->getMessage() + $exceptions[0]->getMessage() ); }, E_USER_DEPRECATED); } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 17e9bab..033895f 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -499,12 +499,12 @@ public function testDeprecated(): void MockSocket::initialize('server.construct', $this); $server = new Server(); $this->assertTrue(MockSocket::isEmpty()); - (new ErrorHandler())->with(function () use ($server) { + (new ErrorHandler())->withAll(function () use ($server) { $this->assertNull($server->getPier()); - }, function (ErrorException $e) { + }, function ($exceptions, $result) { $this->assertEquals( 'getPier() is deprecated and will be removed in future version. Use getRemoteName() instead.', - $e->getMessage() + $exceptions[0]->getMessage() ); }, E_USER_DEPRECATED); } From 5f29b1f1b38a14b0a1bfc442ea3657d06ecd19fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 29 Oct 2022 14:20:08 +0200 Subject: [PATCH 19/21] Documentation --- README.md | 20 +-- docs/Changelog.md | 6 +- docs/Classes/Client.md | 6 - docs/Classes/Connection.md | 6 - docs/Classes/Message.md | 6 - docs/Classes/Server.md | 313 ------------------------------------- docs/Contributing.md | 8 + docs/Message.md | 4 +- 8 files changed, 23 insertions(+), 346 deletions(-) delete mode 100644 docs/Classes/Client.md delete mode 100644 docs/Classes/Connection.md delete mode 100644 docs/Classes/Message.md delete mode 100644 docs/Classes/Server.md diff --git a/README.md b/README.md index 360192f..ab55cf2 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,8 @@ It does not include convenience operations such as listeners and implicit error ## Documentation -- [Client overwiew](docs/Client.md) -- [Server overview](docs/Server.md) -- [Classes](docs/Classes/Classes.md) +- [Client](docs/Client.md) +- [Server](docs/Server.md) - [Examples](docs/Examples.md) - [Changelog](docs/Changelog.md) - [Contributing](docs/Contributing.md) @@ -43,17 +42,18 @@ $client->close(); ## Server -The library contains a websocket [server](docs/Server.md). +The library contains a rudimentary single stream/single thread [server](docs/Server.md). It internally supports Upgrade handshake and implicit close and ping/pong operations. -Preferred operation is using the listener function, but optional operations exist. + +Note that it does **not** support threading or automatic association ot continuous client requests. +If you require this kind of server behavior, you need to build it on top of provided server implementation. ```php $server = new WebSocket\Server(); -$server->listen(function ($message, $connection = null) { - echo "Got {$message->getContent()}\n"; - if (!$connection) return; // Current connection is closed - $connection->text('Sending message to client'); -}); +$server->accept(); +$message = $server->receive(); +$server->text($message); +$server->close(); ``` ### License and Contributors diff --git a/docs/Changelog.md b/docs/Changelog.md index 3763731..c9e086a 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -8,11 +8,11 @@ ### `1.6.0` * Connection separate from Client and Server (@sirn-se) - * getPier() deprecated, replaces by getRemoteName() (@sirn-se) - * Client accepts Psr\Http\Message\UriInterface as input for URI:s (@sirn-se) + * getPier() deprecated, replaced by getRemoteName() (@sirn-se) + * Client accepts `Psr\Http\Message\UriInterface` as input for URI:s (@sirn-se) * Bad URI throws exception when Client is instanciated, previously when used (@sirn-se) - * Major internal refactoring (@sirn-se) * Preparations for multiple conection and listeners (@sirn-se) + * Major internal refactoring (@sirn-se) ## `v1.5` diff --git a/docs/Classes/Client.md b/docs/Classes/Client.md deleted file mode 100644 index 1e825d8..0000000 --- a/docs/Classes/Client.md +++ /dev/null @@ -1,6 +0,0 @@ -Classes: [Client](Client.md) • Server - -# Client class - -Websocket Client class. - diff --git a/docs/Classes/Connection.md b/docs/Classes/Connection.md deleted file mode 100644 index a1e8bc1..0000000 --- a/docs/Classes/Connection.md +++ /dev/null @@ -1,6 +0,0 @@ -Classes: [Client](Client.md) • Server - -# Connection class - -Websocket Connection class. - diff --git a/docs/Classes/Message.md b/docs/Classes/Message.md deleted file mode 100644 index 3989647..0000000 --- a/docs/Classes/Message.md +++ /dev/null @@ -1,6 +0,0 @@ -Classes: [Client](Client.md) • Server - -# Message class - -Websocket Message class. - diff --git a/docs/Classes/Server.md b/docs/Classes/Server.md deleted file mode 100644 index a1f9e33..0000000 --- a/docs/Classes/Server.md +++ /dev/null @@ -1,313 +0,0 @@ -Classes: [Client](Client.md) • Server - -# Server class - -Websocket Server class. Support multiple connections through the `listen()` method. - -## Class synopsis - -```php -WebSocket\Server implements Psr\Log\LoggerAwareInterface { - - // Magic methods - public __construct(array $options = []) - public __toString() : string - - // Server operations - public listen(Closure $callback) : mixed - public stop(): void - - // Server option functions - public getPort() : int - public setTimeout(int $seconds) : void - public setFragmentSize(int $fragment_size) : self - public getFragmentSize() : int - - // Connection broadcast operations - 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 close(int $status = 1000, mixed $message = 'ttfn') : void - public disconnect() : void - public receive() : mixed - - // Provided by Psr\Log\LoggerAwareTrait - public setLogger(Psr\Log\LoggerInterface $logger) : void - - // Deprecated functions - public accept() : bool - public getPath() : string - public getRequest() : array - public getHeader(string $header_name) : string|null - public getLastOpcode() : string - public getCloseStatus() : int - public isConnected() : bool - public getName() : string|null - public getPeer() : string|null - public getPier() : string|null -} -``` - -## __construct - -Constructor for Websocket Server. - -#### Description - -```php -public function __construct(array $options = []) -``` - -#### Parameters - -###### `options` - -An optional array of parameters. -Name | Type | Default | Description ---- | --- | --- | --- -filter | array | ["text", "binary"] | Array of opcodes to return on receive and listen functions -fragment_size | int | 4096 | Maximum payload size -logger | Psr\Log\LoggerInterface | Psr\Log\NullLogger |A [PSR-3](https://www.php-fig.org/psr/psr-3/) compatible logger -port | int | 8000 | The server port to listen to -return_obj | bool | false | Return a [Message](Message.md) instance on receive function -timeout | int | 5 | Time out in seconds - -#### Return Values - -Returns a new WebSocket\Server instance. - -#### Errors/Exceptions - -Emits [ConnectionException](ConnectionException.md) on failure. - -#### Examples - -```php - 8080, 'timeout' => 60]); - -?> -``` - - -## __toString - -Get string representation of instance. - -#### Description - -```php -public function __toString() : string -``` - -#### Return Values - -Returns a string to represent current instance. - - -## listen - -Set server to listen to incoming requests. - -#### Description - -```php -public function listen(Closure $callback) : mixed -``` - -#### Parameters - -###### `callback` - -A callback function that is triggered whenever the server receives a message matching the filter. - -The callback takes two parameters; -* The [Message](Message/Message.md) that has been received -* The [Connection](Connection.md) the server has receievd on, can be `null` if connection is closed - -If callback function returns non-null value, the listener will halt and return that value. -Otherwise it will continue listening and propagating messages. - -#### Return Values - -Returns any non-null value returned by callback function. - -#### Errors/Exceptions - -Emits [ConnectionException](ConnectionException.md) on failure. - -#### Examples - -Minimal setup that continuously listens to incoming text and binary messages. -```php -listen(function ($message, $connection) { - echo $message->getContent(); -}); -?> -``` - -Listen to all incoming message types and respond with a text message. -```php - ['text', 'binary', 'ping', 'pong', 'close']]); -$server->listen(function ($message, $connection) { - if (!$connection) { - $connection->text("Confirm " . $message->getOpcode()); - } -}); -?> -``` - -Halt listener and return a value to calling code. -```php -listen(function ($message, $connection) { - return $message->getContent(); -}); -echo $content; -?> -``` - -## stop - -Tell server to stop listening to incoming requests. - -#### Description - -```php -public function stop(): void -``` - -#### Examples - -Use stop() in listener. -```php -listen(function ($message, $connection) use ($server) { - echo $message->getContent(); - $server->stop(); - }); - // Do things, listener will be restarted in next loop. -} -?> -``` - -## getPort - -#### Description - -```php -public function getPort(): void -``` - -## setTimeout - -#### Description - -```php -public function setTimeout(int $seconds): void -``` - -## setFragmentSize - -#### Description - -```php -public function setFragmentSize(int $fragment_size): self -``` - -## getFragmentSize - -#### Description - -```php -public function getFragmentSize(): int -``` - -## text - -#### Description - -```php -public function text(string $payload) : void -``` - -## binary - -#### Description - -```php -public function binary(string $payload) : void -``` - -## ping - -#### Description - -```php -public function ping(string $payload = '') : void -``` - -## pong - -#### Description - -```php -public function pong(string $payload = '') : void -``` - -## send - -#### Description - -```php -public function send(mixed $payload, string $opcode = 'text', bool $masked = true) : void -``` - -## close - -#### Description - -```php -public function close(int $status = 1000, mixed $message = 'ttfn') : void -``` - -## disconnect - -#### Description - -```php -public function disconnect() : void -``` - -## receive - -#### Description - -```php -public function receive() : mixed -``` - -## setLogger - -#### Description - -```php -public setLogger(Psr\Log\LoggerInterface $logger) : void -``` diff --git a/docs/Contributing.md b/docs/Contributing.md index 263d868..21ed01d 100644 --- a/docs/Contributing.md +++ b/docs/Contributing.md @@ -12,6 +12,14 @@ Requirements on pull requests; * Code coverage **MUST** remain at 100%. * Code **MUST** adhere to PSR-1 and PSR-12 code standards. +Base your patch on corresponding version branch, and target that version branch in your pull request. + +* `v1.6-master` current version +* `v1.5-master` previous version, bug fixes only +* `v1.4-master` previous version, bug fixes only +* Older versions should not be target of pull requests + + ## Dependency management Install or update dependencies using [Composer](https://getcomposer.org/). diff --git a/docs/Message.md b/docs/Message.md index c6ced83..80df04a 100644 --- a/docs/Message.md +++ b/docs/Message.md @@ -14,7 +14,7 @@ Available classes correspond to opcode; Additionally; * WebSocket\Message\Message - abstract base class for all messages above -* WebSocket\Message\Factory - Factory class to create Msssage instances +* WebSocket\Message\Factory - Factory class to create Message instances ## Message abstract class synopsis @@ -38,7 +38,7 @@ WebSocket\Message\Message { ```php WebSocket\Message\Factory { - public create(string $opcode, string $payload = '') : Message + public create(string $opcode, string $payload = '') : Message; } ``` From a92914b4f04b167cdeb19da7a7d2998818b42888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Sat, 29 Oct 2022 18:32:15 +0200 Subject: [PATCH 20/21] Documentation --- docs/Server.md | 2 +- examples/echoserver.php | 98 ++++++++++++++++++----------------------- 2 files changed, 44 insertions(+), 56 deletions(-) diff --git a/docs/Server.md b/docs/Server.md index a44a1ac..9e12e07 100644 --- a/docs/Server.md +++ b/docs/Server.md @@ -35,7 +35,7 @@ WebSocket\Server { public getRemoteName() : string|null; public getLastOpcode() : string; public getCloseStatus() : int; - public isConnected() : bool + public isConnected() : bool; public setTimeout(int $seconds) : void; public setFragmentSize(int $fragment_size) : self; public getFragmentSize() : int; diff --git a/examples/echoserver.php b/examples/echoserver.php index 3f7db53..a85e564 100644 --- a/examples/echoserver.php +++ b/examples/echoserver.php @@ -32,7 +32,7 @@ echo "> Using logger\n"; } -// Initiate server +// Initiate server. try { $server = new Server($options); } catch (ConnectionException $e) { @@ -42,58 +42,46 @@ echo "> Listening to port {$server->getPort()}\n"; -$server->listen(function ($message, $connection = null) use ($server) { - $content = $message->getContent(); - $opcode = $message->getOpcode(); - $peer = $connection ? $connection->getPeer() : '(closed)'; - echo "> Got '{$content}' [opcode: {$opcode}, peer: {$peer}]\n"; - - // Connection closed, can't respond - if (!$connection) { - echo "> Connection closed\n"; - return; // Continue listening - } - - if (in_array($opcode, ['ping', 'pong'])) { - $connection->text($content); - echo "< Sent '{$content}' [opcode: text, peer: {$peer}]\n"; - return; // Continue listening +// Force quit to close server +while (true) { + try { + while ($server->accept()) { + echo "> Accepted on port {$server->getPort()}\n"; + while (true) { + $message = $server->receive(); + $opcode = $server->getLastOpcode(); + if (is_null($message)) { + echo "> Closing connection\n"; + continue 2; + } + echo "> Got '{$message}' [opcode: {$opcode}]\n"; + if (in_array($opcode, ['ping', 'pong'])) { + $server->send($message); + continue; + } + // Allow certain string to trigger server action + switch ($message) { + case 'exit': + echo "> Client told me to quit. Bye bye.\n"; + $server->close(); + echo "> Close status: {$server->getCloseStatus()}\n"; + exit; + case 'headers': + $server->text(implode("\r\n", $server->getRequest())); + break; + case 'ping': + $server->ping($message); + break; + case 'auth': + $auth = $server->getHeader('Authorization'); + $server->text("{$auth} - {$message}"); + break; + default: + $server->text($message); + } + } + } + } catch (ConnectionException $e) { + echo "> ERROR: {$e->getMessage()}\n"; } - - // Allow certain string to trigger server action - switch ($content) { - case 'auth': - $auth = "{$server->getHeader('Authorization')} - {$content}"; - $connection->text($auth); - echo "< Sent '{$auth}' [opcode: text, peer: {$peer}]\n"; - break; - case 'close': - $connection->close(1000, $content); - echo "< Sent '{$content}' [opcode: close, peer: {$peer}]\n"; - break; - case 'exit': - echo "> Client told me to quit.\n"; - $server->close(); - return true; // Stop listener - case 'headers': - $headers = trim(implode("\r\n", $server->getRequest())); - $connection->text($headers); - echo "< Sent '{$headers}' [opcode: text, peer: {$peer}]\n"; - break; - case 'ping': - $connection->ping($content); - echo "< Sent '{$content}' [opcode: ping, peer: {$peer}]\n"; - break; - case 'pong': - $connection->pong($content); - echo "< Sent '{$content}' [opcode: pong, peer: {$peer}]\n"; - break; - case 'stop': - $server->stop(); - echo "> Client told me to stop listening.\n"; - break; - default: - $connection->text($content); - echo "< Sent '{$content}' [opcode: text, peer: {$peer}]\n"; - } -}); +} From 0b22122d109dfe43365d59e27a18020d9dab8854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Jensen?= Date: Tue, 1 Nov 2022 19:30:53 +0100 Subject: [PATCH 21/21] Documentation --- docs/Contributing.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/Contributing.md b/docs/Contributing.md index 21ed01d..c68ab83 100644 --- a/docs/Contributing.md +++ b/docs/Contributing.md @@ -16,7 +16,6 @@ Base your patch on corresponding version branch, and target that version branch * `v1.6-master` current version * `v1.5-master` previous version, bug fixes only -* `v1.4-master` previous version, bug fixes only * Older versions should not be target of pull requests