From d38f4321b69e52519ec90e6ffbd9af61f0cd3bff Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 4 Oct 2024 11:36:50 +0200 Subject: [PATCH 01/24] Add WebSocket client implementation --- src/main/php/websocket/WebSocket.class.php | 126 +++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100755 src/main/php/websocket/WebSocket.class.php diff --git a/src/main/php/websocket/WebSocket.class.php b/src/main/php/websocket/WebSocket.class.php new file mode 100755 index 0000000..af6beaf --- /dev/null +++ b/src/main/php/websocket/WebSocket.class.php @@ -0,0 +1,126 @@ +socket= $arg; + $this->path= '/'; + } else { + $url= parse_url($arg); + if ('wss' === $url['scheme']) { + $this->socket= new CryptoSocket($url['host'], $url['port'] ?? 443); + $this->socket->cryptoImpl= STREAM_CRYPTO_METHOD_ANY_CLIENT; + } else { + $this->socket= new Socket($url['host'], $url['port'] ?? 80); + } + $this->path= $url['path'] ?? '/'; + } + $this->origin= $origin; + } + + /** + * Attach listener + * + * @param websocket.Listener $listener + * @return self + */ + public function listening(Listener $listener) { + $this->listener= $listener; + return $this; + } + + /** + * Connects to websocket endpoint and performs handshake + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-WebSocket-Accept + * @param [:string|string[]] $headers + * @throws peer.ProtocolException + * @return void + */ + public function connect($headers= []) { + if ($this->socket->isConnected()) return; + + $key= base64_encode(random_bytes(16)); + $headers+= ['Host' => $this->socket->host, 'Origin' => $this->origin]; + $this->socket->connect(); + $this->socket->write( + "GET {$this->path} HTTP/1.1\r\n". + "Upgrade: websocket\r\n". + "Sec-WebSocket-Key: {$key}\r\n". + "Sec-WebSocket-Version: 13\r\n". + "Connection: Upgrade\r\n" + ); + foreach ($headers as $name => $values) { + foreach ((array)$values as $value) { + $this->socket->write("{$name}: {$value}\r\n"); + } + } + $this->socket->write("\r\n"); + + sscanf($this->socket->readLine(), "HTTP/%s %d %[^\r]", $version, $status, $message); + if (101 !== $status) { + $this->socket->close(); + throw new ProtocolException('Unexpected response '.$status.' '.$message); + } + + $headers= []; + while ($line= $this->socket->readLine()) { + sscanf($line, "%[^:]: %[^\r]", $header, $value); + $headers[$header][]= $value; + } + + $accept= $headers['Sec-Websocket-Accept'][0] ?? ''; + $expect= base64_encode(sha1($key.Handshake::GUID, true)); + if ($accept !== $expect) { + $this->socket->close(); + throw new ProtocolException('Accept key mismatch, have '.$accept.', expect '.$expect); + } + + $this->socket->setTimeout(600.0); + $this->conn= new Connection( + $this->socket, + (int)$this->socket->getHandle(), + $this->listener ?? new class() extends Listener { + public function open($connection) { } + public function message($connection, $message) { } + public function close($connection) { } + }, + $this->path, + $headers + ); + $this->conn->open(); + } + + public function send($arg) { + if (!$this->socket->isConnected()) throw new ProtocolException('Not connected'); + + $this->conn->send($arg); + } + + public function receive($timeout= null) { + if (!$this->socket->isConnected()) throw new ProtocolException('Not connected'); + + if (null !== $timeout && !$this->socket->canRead($timeout)) return; + foreach ($this->conn->receive() as $opcode => $message) { + switch ($opcode) { + case Opcodes::BINARY: $this->conn->on(new Bytes($message)); break; + case Opcodes::TEXT: $this->conn->on($message); break; + } + yield $opcode => $message; + } + } + + public function close() { + if (!$this->socket->isConnected()) return; + + $this->conn->close(); + $this->socket->close(); + } +} \ No newline at end of file From 0fb766363b611d3d08bf995028f3f85e931e5fc5 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 4 Oct 2024 16:39:00 +0200 Subject: [PATCH 02/24] Set host --- src/test/php/websocket/unittest/Channel.class.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/php/websocket/unittest/Channel.class.php b/src/test/php/websocket/unittest/Channel.class.php index c780980..b75dab0 100755 --- a/src/test/php/websocket/unittest/Channel.class.php +++ b/src/test/php/websocket/unittest/Channel.class.php @@ -9,6 +9,7 @@ class Channel extends Socket { public function __construct($in= '') { $this->in= $in; $this->out= ''; + $this->host= 'test'; } public function connect($timeout= 2) { From e03768db4f2330c2e72649fc8b70601439c08ac3 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 4 Oct 2024 16:50:05 +0200 Subject: [PATCH 03/24] Add tests for WebSocket client implementation --- src/main/php/websocket/WebSocket.class.php | 122 +++++++++++++-- .../unittest/WebSocketTest.class.php | 141 ++++++++++++++++++ 2 files changed, 248 insertions(+), 15 deletions(-) create mode 100755 src/test/php/websocket/unittest/WebSocketTest.class.php diff --git a/src/main/php/websocket/WebSocket.class.php b/src/main/php/websocket/WebSocket.class.php index af6beaf..96dee1b 100755 --- a/src/main/php/websocket/WebSocket.class.php +++ b/src/main/php/websocket/WebSocket.class.php @@ -1,19 +1,33 @@ socket= $arg; + /** + * Creates a new instance + * + * @param peer.Socket|string $endpoint, e.g. "wss://example.com" + * @param string $origin + */ + public function __construct($endpoint, $origin= 'localhost') { + if ($endpoint instanceof Socket) { + $this->socket= $endpoint; $this->path= '/'; } else { - $url= parse_url($arg); + $url= parse_url($endpoint); if ('wss' === $url['scheme']) { $this->socket= new CryptoSocket($url['host'], $url['port'] ?? 443); $this->socket->cryptoImpl= STREAM_CRYPTO_METHOD_ANY_CLIENT; @@ -25,9 +39,20 @@ public function __construct($arg, $origin= 'localhost') { $this->origin= $origin; } + /** @return string */ + public function path() { return $this->path; } + + /** @return ?peer.Socket */ + public function socket() { return $this->socket; } + + /** @param function(int): string */ + public function random($function) { + $this->random= $function; + } + /** * Attach listener - * + * * @param websocket.Listener $listener * @return self */ @@ -47,7 +72,7 @@ public function listening(Listener $listener) { public function connect($headers= []) { if ($this->socket->isConnected()) return; - $key= base64_encode(random_bytes(16)); + $key= base64_encode(($this->random)(16)); $headers+= ['Host' => $this->socket->host, 'Origin' => $this->origin]; $this->socket->connect(); $this->socket->write( @@ -98,29 +123,96 @@ public function close($connection) { } $this->conn->open(); } - public function send($arg) { + /** + * Sends a ping + * + * @param string $payload + * @return void + * @throws peer.ProtocolException + */ + public function ping($payload= '') { if (!$this->socket->isConnected()) throw new ProtocolException('Not connected'); - $this->conn->send($arg); + $this->conn->message(Opcodes::PING, $payload, ($this->random)(4)); } + /** + * Sends a message + * + * @param util.Bytes|string $message + * @return void + * @throws peer.ProtocolException + */ + public function send($message) { + if (!$this->socket->isConnected()) throw new ProtocolException('Not connected'); + + if ($message instanceof Bytes) { + $this->conn->message(Opcodes::BINARY, $message, ($this->random)(4)); + } else { + $this->conn->message(Opcodes::TEXT, $message, ($this->random)(4)); + } + } + + /** + * Receive messages, handling PING and CLOSE + * + * @return iterable + * @throws peer.ProtocolException + */ public function receive($timeout= null) { if (!$this->socket->isConnected()) throw new ProtocolException('Not connected'); if (null !== $timeout && !$this->socket->canRead($timeout)) return; - foreach ($this->conn->receive() as $opcode => $message) { + foreach ($this->conn->receive() as $opcode => $packet) { switch ($opcode) { - case Opcodes::BINARY: $this->conn->on(new Bytes($message)); break; - case Opcodes::TEXT: $this->conn->on($message); break; + case Opcodes::BINARY: + $message= new Bytes($packet); + $this->conn->on($message); + yield $message; + break; + + case Opcodes::TEXT: + $this->conn->on($packet); + yield $packet; + break; + + case Opcodes::PING: + $this->conn->message(Opcodes::PONG, $packet, ($this->random)(4)); + break; + + case Opcodes::CLOSE: + $close= unpack('ncode/a*message', $packet); + $this->conn->close($close['code'], $close['message']); + $this->socket->close(); + + // 1000 is a normal close, all others indicate an error + if (1000 === $close['code']) return; + throw new ProtocolException('Connection closed (#'.$close['code'].'): '.$close['message']); } - yield $opcode => $message; } } - public function close() { + /** + * Closes connection + * + * @param int $code + * @param string $message + * @return void + */ + public function close($code= 1000, $message= '') { if (!$this->socket->isConnected()) return; - $this->conn->close(); + try { + $this->conn->message(Opcodes::CLOSE, pack('n', $code).$message, ($this->random)(4)); + } catch (Throwable $ignored) { + // ... + } + $this->conn->close($code, $message); $this->socket->close(); } + + /** Destructor - ensures connection is closed */ + public function __destruct() { + $this->close(); + } } \ No newline at end of file diff --git a/src/test/php/websocket/unittest/WebSocketTest.class.php b/src/test/php/websocket/unittest/WebSocketTest.class.php new file mode 100755 index 0000000..28f3ed3 --- /dev/null +++ b/src/test/php/websocket/unittest/WebSocketTest.class.php @@ -0,0 +1,141 @@ +random(fn($bytes) => str_repeat('*', $bytes)); + return $fixture; + } + + #[Test] + public function socket_argument() { + $s= new Socket('example.com', 8443); + Assert::equals($s, (new WebSocket($s))->socket()); + } + + #[Test, Values([['ws://example.com', 80], ['wss://example.com', 443]])] + public function default_port($url, $expected) { + Assert::equals($expected, (new WebSocket($url))->socket()->port); + } + + #[Test, Values([['ws://example.com:8080', 8080], ['wss://example.com:8443', 8443]])] + public function port($url, $expected) { + Assert::equals($expected, (new WebSocket($url))->socket()->port); + } + + #[Test, Expect(class: ProtocolException::class, message: 'Unexpected response 400 Bad Request')] + public function no_websocket_to_connect_to() { + $fixture= new WebSocket(new Channel( + "HTTP/1.1 400 Bad Request\r\n". + "Content-Length: 18\r\n". + "\r\n". + "No websocket here!" + )); + $fixture->connect(); + } + + #[Test, Expect(class: ProtocolException::class, message: '/Accept key mismatch, have .+, expect .+/')] + public function handshake_mismatch() { + $fixture= new WebSocket(new Channel( + "HTTP/1.1 101 Switching Protocols\r\n". + "Connection: Upgrade\r\n". + "Upgrade: websocket\r\n". + "Sec-Websocket-Accept: EGUNIQA7j7p+kiqxH/TKPdu8A4g=\r\n". + "\r\n" + )); + $fixture->connect(); + } + + #[Test] + public function connect() { + $fixture= $this->fixture(); + $fixture->connect(); + } + + #[Test] + public function receive_text() { + $fixture= $this->fixture("\x81\x04Test"); + $fixture->connect(); + + Assert::equals(['Test'], iterator_to_array($fixture->receive())); + } + + #[Test] + public function receive_binary() { + $fixture= $this->fixture("\x82\x08GIF89..."); + $fixture->connect(); + + Assert::equals([new Bytes('GIF89...')], iterator_to_array($fixture->receive())); + } + + #[Test] + public function send_text() { + $fixture= $this->fixture(); + $fixture->connect(); + $fixture->send('Test'); + + Assert::equals( + "GET / HTTP/1.1\r\n". + "Upgrade: websocket\r\n". + "Sec-WebSocket-Key: KioqKioqKioqKioqKioqKg==\r\n". + "Sec-WebSocket-Version: 13\r\n". + "Connection: Upgrade\r\n". + "Host: test\r\n". + "Origin: localhost\r\n\r\n". + "\x81\x84****\176\117\131\136", + $fixture->socket()->out + ); + } + + #[Test] + public function send_bytes() { + $fixture= $this->fixture(); + $fixture->connect(); + $fixture->send(new Bytes('GIF89...')); + + Assert::equals( + "GET / HTTP/1.1\r\n". + "Upgrade: websocket\r\n". + "Sec-WebSocket-Key: KioqKioqKioqKioqKioqKg==\r\n". + "Sec-WebSocket-Version: 13\r\n". + "Connection: Upgrade\r\n". + "Host: test\r\n". + "Origin: localhost\r\n\r\n". + "\x82\x88****\155\143\154\022\023\004\004\004", + $fixture->socket()->out + ); + } + + #[Test] + public function pings_are_answered() { + $fixture= $this->fixture("\x89\x01!"); + $fixture->connect(); + + Assert::equals([], iterator_to_array($fixture->receive())); + Assert::equals( + "GET / HTTP/1.1\r\n". + "Upgrade: websocket\r\n". + "Sec-WebSocket-Key: KioqKioqKioqKioqKioqKg==\r\n". + "Sec-WebSocket-Version: 13\r\n". + "Connection: Upgrade\r\n". + "Host: test\r\n". + "Origin: localhost\r\n\r\n". + "\x8a\x81****\013", + $fixture->socket()->out + ); + } +} \ No newline at end of file From a1db52df96caf5fb415a6e09c6993a9e1f44dab7 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 4 Oct 2024 16:50:54 +0200 Subject: [PATCH 04/24] Yield CLOSE opcode instead of handling them --- .../websocket/protocol/Connection.class.php | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/main/php/websocket/protocol/Connection.class.php b/src/main/php/websocket/protocol/Connection.class.php index a9fb22f..968c137 100755 --- a/src/main/php/websocket/protocol/Connection.class.php +++ b/src/main/php/websocket/protocol/Connection.class.php @@ -63,7 +63,7 @@ public function on($payload) { } /** - * Opens connection + * Closes connection * * @return void */ @@ -102,10 +102,7 @@ public function receive() { $continue= []; do { $packet= $this->read(2); - if (strlen($packet) < 2) { - $this->socket->close(); - return; - } + if (strlen($packet) < 2) return; $final= $packet[0] & "\x80"; $opcode= $packet[0] & "\x0f"; @@ -118,8 +115,7 @@ public function receive() { // Verify opcode, send protocol error if unkown if (!isset($packets[$opcode])) { - $this->transmit(Opcodes::CLOSE, pack('n', 1002)); - $this->socket->close(); + yield Opcodes::CLOSE => pack('n', 1002); return; } @@ -133,8 +129,7 @@ public function receive() { // Verify length if ($read > self::MAXLENGTH) { - $this->transmit(Opcodes::CLOSE, pack('n', 1003)); - $this->socket->close(); + yield Opcodes::CLOSE => pack('n', 1003); return; } @@ -160,6 +155,29 @@ public function receive() { } while ($continue); } + /** + * Sends an message + * + * @param string $type One of the class constants TEXT | BINARY | CLOSE | PING | PONG + * @param string $payload + * @param string $mask 4 bytes + * @return void + */ + public function message($type, $payload, $mask) { + $length= strlen($payload); + $data= ''; + for ($i = 0; $i < $length; $i+= 4) { + $data.= $mask ^ substr($payload, $i, 4); + } + + if ($length < 126) { + $this->socket->write(("\x80" | $type).("\x80" | chr($length)).$mask.$data); + } else if ($length < 65536) { + $this->socket->write(("\x80" | $type)."\xfe".pack('n', $length).$mask.$data); + } else { + $this->socket->write(("\x80" | $type)."\xff".pack('J', $length).$mask.$data); + } + } /** * Transmits an answer @@ -168,7 +186,7 @@ public function receive() { * @param string $payload * @return void */ - public function transmit($type, $payload) { + public function answer($type, $payload) { $length= strlen($payload); if ($length < 126) { $this->socket->write(("\x80" | $type).chr($length).$payload); @@ -187,9 +205,9 @@ public function transmit($type, $payload) { */ public function send($arg) { if ($arg instanceof Bytes) { - $this->transmit(Opcodes::BINARY, $arg); + $this->answer(Opcodes::BINARY, $arg); } else { - $this->transmit(Opcodes::TEXT, $arg); + $this->answer(Opcodes::TEXT, $arg); } } } \ No newline at end of file From 91fe30361588e90a779f86903d8de1768dc87884 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 4 Oct 2024 16:51:26 +0200 Subject: [PATCH 05/24] Simplify handling CLOSE opcode --- .../php/websocket/protocol/Messages.class.php | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/main/php/websocket/protocol/Messages.class.php b/src/main/php/websocket/protocol/Messages.class.php index 7a41ff9..821e588 100755 --- a/src/main/php/websocket/protocol/Messages.class.php +++ b/src/main/php/websocket/protocol/Messages.class.php @@ -23,7 +23,7 @@ public function next($socket, $i) { switch ($opcode) { case Opcodes::TEXT: if (!preg_match('//u', $payload)) { - $conn->transmit(Opcodes::CLOSE, pack('n', 1007)); + $conn->answer(Opcodes::CLOSE, pack('n', 1007)); $this->logging->log($i, 'TEXT', 1007); $socket->close(); break; @@ -39,7 +39,7 @@ public function next($socket, $i) { break; case Opcodes::PING: // Answer a PING frame with a PONG - $conn->transmit(Opcodes::PONG, $payload); + $conn->answer(Opcodes::PONG, $payload); $this->logging->log($i, 'PING', true); break; @@ -48,21 +48,20 @@ public function next($socket, $i) { case Opcodes::CLOSE: // Close connection if ('' === $payload) { - $conn->transmit(Opcodes::CLOSE, pack('n', 1000)); - $this->logging->log($i, 'CLOSE', 1000); + $close= ['code' => 1000]; } else { - $result= unpack('ncode/a*message', $payload); - if (!preg_match('//u', $result['message'])) { - $conn->transmit(Opcodes::CLOSE, pack('n', 1007)); - $this->logging->log($i, 'CLOSE', 1007); - } else if ($result['code'] > 2999 || in_array($result['code'], [1000, 1001, 1002, 1003, 1007, 1008, 1009, 1010, 1011])) { - $conn->transmit(Opcodes::CLOSE, $payload); - $this->logging->log($i, 'CLOSE', $result['code']); + $close= unpack('ncode/a*message', $payload); + if (!preg_match('//u', $close['message'])) { + $close= ['code' => 1007]; + } else if ($close['code'] > 2999 || in_array($close['code'], [1000, 1001, 1002, 1003, 1007, 1008, 1009, 1010, 1011])) { + // Answer with client code and message } else { - $conn->transmit(Opcodes::CLOSE, pack('n', 1002)); - $this->logging->log($i, 'CLOSE', 1002); + $close= ['code' => 1002]; } } + + $conn->answer(Opcodes::CLOSE, pack('na*', $close['code'], $close['message'] ?? '')); + $this->logging->log($i, 'CLOSE', $close); $socket->close(); break; } From d9909601674cc235c332b2d247821960c652a235 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 4 Oct 2024 16:52:09 +0200 Subject: [PATCH 06/24] Adjust tests to socket not being closed by Connection This is now handled inside the Messages protocol implementation --- .../unittest/ConnectionTest.class.php | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/src/test/php/websocket/unittest/ConnectionTest.class.php b/src/test/php/websocket/unittest/ConnectionTest.class.php index 22473e3..1601a72 100755 --- a/src/test/php/websocket/unittest/ConnectionTest.class.php +++ b/src/test/php/websocket/unittest/ConnectionTest.class.php @@ -96,32 +96,21 @@ public function fragmented_text_with_ping_inbetween() { } #[Test, Values(['', "\x81"])] - public function closes_connection_on_invalid_packet($bytes) { - $channel= (new Channel($bytes))->connect(); - $this->receive($channel); - - Assert::equals('', $channel->out); - Assert::false($channel->isConnected(), 'Channel closed'); + public function closes_connection_on_empty_packet($bytes) { + $received= $this->receive(new Channel($bytes)); + Assert::equals([], $received); } #[Test] public function closes_connection_on_invalid_opcode() { - $channel= (new Channel("\x8f\x00"))->connect(); - $this->receive($channel); - - // 0x80 | 0x08 (CLOSE), 2 bytes, pack("n", 1002) - Assert::equals("\x88\x02\x03\xea", $channel->out); - Assert::false($channel->isConnected(), 'Channel closed'); + $received= $this->receive(new Channel("\x8f\x00")); + Assert::equals([[Opcodes::CLOSE => pack('n', 1002)]], $received); } #[Test] public function closes_connection_when_exceeding_max_length() { - $channel= (new Channel("\x81\x7f".pack('J', Connection::MAXLENGTH + 1)))->connect(); - $this->receive($channel); - - // 0x80 | 0x08 (CLOSE), 2 bytes, pack("n", 1003) - Assert::equals("\x88\x02\x03\xeb", $channel->out); - Assert::false($channel->isConnected(), 'Channel closed'); + $received= $this->receive(new Channel("\x81\x7f".pack('J', Connection::MAXLENGTH + 1))); + Assert::equals([[Opcodes::CLOSE => pack('n', 1003)]], $received); } #[Test] From 533df0c8773054db9632dbf013e73b411af863fc Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 4 Oct 2024 16:54:33 +0200 Subject: [PATCH 07/24] Restore PHP < 7.4 compatibility --- src/test/php/websocket/unittest/WebSocketTest.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/php/websocket/unittest/WebSocketTest.class.php b/src/test/php/websocket/unittest/WebSocketTest.class.php index 28f3ed3..3c14cc2 100755 --- a/src/test/php/websocket/unittest/WebSocketTest.class.php +++ b/src/test/php/websocket/unittest/WebSocketTest.class.php @@ -17,7 +17,7 @@ private function fixture($payload= '') { "\r\n". $payload )); - $fixture->random(fn($bytes) => str_repeat('*', $bytes)); + $fixture->random(function($bytes) { return str_repeat('*', $bytes); }); return $fixture; } From ab58633268bbe6ecd232dcc09dd7868608dee24d Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 4 Oct 2024 17:02:11 +0200 Subject: [PATCH 08/24] Handle Opcodes::TEXT first --- src/main/php/websocket/WebSocket.class.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/php/websocket/WebSocket.class.php b/src/main/php/websocket/WebSocket.class.php index 96dee1b..1643bb5 100755 --- a/src/main/php/websocket/WebSocket.class.php +++ b/src/main/php/websocket/WebSocket.class.php @@ -165,17 +165,17 @@ public function receive($timeout= null) { if (null !== $timeout && !$this->socket->canRead($timeout)) return; foreach ($this->conn->receive() as $opcode => $packet) { switch ($opcode) { + case Opcodes::TEXT: + $this->conn->on($packet); + yield $packet; + break; + case Opcodes::BINARY: $message= new Bytes($packet); $this->conn->on($message); yield $message; break; - case Opcodes::TEXT: - $this->conn->on($packet); - yield $packet; - break; - case Opcodes::PING: $this->conn->message(Opcodes::PONG, $packet, ($this->random)(4)); break; From dccaaf238a943de973c19168369cfb23aaf4d1ee Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 4 Oct 2024 17:02:58 +0200 Subject: [PATCH 09/24] Do not answer PONGs --- src/main/php/websocket/WebSocket.class.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/php/websocket/WebSocket.class.php b/src/main/php/websocket/WebSocket.class.php index 1643bb5..9ed5f33 100755 --- a/src/main/php/websocket/WebSocket.class.php +++ b/src/main/php/websocket/WebSocket.class.php @@ -180,6 +180,9 @@ public function receive($timeout= null) { $this->conn->message(Opcodes::PONG, $packet, ($this->random)(4)); break; + case Opcodes::PONG: // Do not answer PONGs + break; + case Opcodes::CLOSE: $close= unpack('ncode/a*message', $packet); $this->conn->close($close['code'], $close['message']); From eb573dbd74b5f609926e7f823f081831c5dc0a20 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 4 Oct 2024 18:36:08 +0200 Subject: [PATCH 10/24] Test close() --- .../php/websocket/unittest/WebSocketTest.class.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/test/php/websocket/unittest/WebSocketTest.class.php b/src/test/php/websocket/unittest/WebSocketTest.class.php index 3c14cc2..ea7915c 100755 --- a/src/test/php/websocket/unittest/WebSocketTest.class.php +++ b/src/test/php/websocket/unittest/WebSocketTest.class.php @@ -64,6 +64,17 @@ public function handshake_mismatch() { public function connect() { $fixture= $this->fixture(); $fixture->connect(); + + Assert::true($fixture->socket()->isConnected()); + } + + #[Test] + public function close() { + $fixture= $this->fixture(); + $fixture->connect(); + $fixture->close(); + + Assert::false($fixture->socket()->isConnected()); } #[Test] From 65591c60c546c3349e8ff654eab83facdb7440b3 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Oct 2024 09:51:11 +0200 Subject: [PATCH 11/24] Add connected() --- src/main/php/websocket/WebSocket.class.php | 3 +++ .../php/websocket/unittest/WebSocketTest.class.php | 13 +++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/php/websocket/WebSocket.class.php b/src/main/php/websocket/WebSocket.class.php index 9ed5f33..b50b78f 100755 --- a/src/main/php/websocket/WebSocket.class.php +++ b/src/main/php/websocket/WebSocket.class.php @@ -42,6 +42,9 @@ public function __construct($endpoint, $origin= 'localhost') { /** @return string */ public function path() { return $this->path; } + /** @return bool */ + public function connected() { return $this->socket->isConnected(); } + /** @return ?peer.Socket */ public function socket() { return $this->socket; } diff --git a/src/test/php/websocket/unittest/WebSocketTest.class.php b/src/test/php/websocket/unittest/WebSocketTest.class.php index ea7915c..a789778 100755 --- a/src/test/php/websocket/unittest/WebSocketTest.class.php +++ b/src/test/php/websocket/unittest/WebSocketTest.class.php @@ -65,7 +65,7 @@ public function connect() { $fixture= $this->fixture(); $fixture->connect(); - Assert::true($fixture->socket()->isConnected()); + Assert::true($fixture->connected()); } #[Test] @@ -74,7 +74,7 @@ public function close() { $fixture->connect(); $fixture->close(); - Assert::false($fixture->socket()->isConnected()); + Assert::false($fixture->connected()); } #[Test] @@ -93,6 +93,15 @@ public function receive_binary() { Assert::equals([new Bytes('GIF89...')], iterator_to_array($fixture->receive())); } + #[Test] + public function handle_graceful_server_close() { + $fixture= $this->fixture("\x88\x02\x03\xe8"); + $fixture->connect(); + + Assert::equals([], iterator_to_array($fixture->receive())); + Assert::false($fixture->connected()); + } + #[Test] public function send_text() { $fixture= $this->fixture(); From 9cbd98e6dcb63ba64bc23b976d620929082cd312 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Oct 2024 09:52:07 +0200 Subject: [PATCH 12/24] QA: WS --- .../php/websocket/protocol/Opcodes.class.php | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/php/websocket/protocol/Opcodes.class.php b/src/main/php/websocket/protocol/Opcodes.class.php index dd1cd34..12e63ef 100755 --- a/src/main/php/websocket/protocol/Opcodes.class.php +++ b/src/main/php/websocket/protocol/Opcodes.class.php @@ -4,14 +4,14 @@ * WebSocket opcodes enumeration * * @see https://tools.ietf.org/html/rfc6455 - * @test xp://web.unittest.protocol.OpcodesTest + * @test web.unittest.protocol.OpcodesTest */ class Opcodes { - const TEXT = "\x01"; - const BINARY = "\x02"; - const CLOSE = "\x08"; - const PING = "\x09"; - const PONG = "\x0a"; + const TEXT = "\x01"; + const BINARY= "\x02"; + const CLOSE = "\x08"; + const PING = "\x09"; + const PONG = "\x0a"; /** * Returns an opcode name for a given opcode @@ -21,11 +21,11 @@ class Opcodes { */ public static function nameOf($opcode) { static $opcodes= [ - self::TEXT => 'TEXT', - self::BINARY => 'BINARY', - self::CLOSE => 'CLOSE', - self::PING => 'PING', - self::PONG => 'PONG', + self::TEXT => 'TEXT', + self::BINARY => 'BINARY', + self::CLOSE => 'CLOSE', + self::PING => 'PING', + self::PONG => 'PONG', ]; return isset($opcodes[$opcode]) ? $opcodes[$opcode] : sprintf('UNKNOWN(0x%02x)', ord($opcode)); From c03e6bcfebc4cc07aac8d34551f6854ad1ff8c09 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Oct 2024 09:53:50 +0200 Subject: [PATCH 13/24] Optimize case when no listener is provided --- src/main/php/websocket/WebSocket.class.php | 6 +----- src/main/php/websocket/protocol/Connection.class.php | 10 +++++----- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/main/php/websocket/WebSocket.class.php b/src/main/php/websocket/WebSocket.class.php index b50b78f..3c9db41 100755 --- a/src/main/php/websocket/WebSocket.class.php +++ b/src/main/php/websocket/WebSocket.class.php @@ -115,11 +115,7 @@ public function connect($headers= []) { $this->conn= new Connection( $this->socket, (int)$this->socket->getHandle(), - $this->listener ?? new class() extends Listener { - public function open($connection) { } - public function message($connection, $message) { } - public function close($connection) { } - }, + $this->listener, $this->path, $headers ); diff --git a/src/main/php/websocket/protocol/Connection.class.php b/src/main/php/websocket/protocol/Connection.class.php index 968c137..31290a8 100755 --- a/src/main/php/websocket/protocol/Connection.class.php +++ b/src/main/php/websocket/protocol/Connection.class.php @@ -19,11 +19,11 @@ class Connection { * * @param peer.Socket $socket * @param int $id - * @param websocket.Listener $listener + * @param ?websocket.Listener $listener * @param string $path * @param [:var] $headers */ - public function __construct($socket, $id, Listener $listener, $path= '/', $headers= []) { + public function __construct($socket, $id, $listener, $path= '/', $headers= []) { $this->socket= $socket; $this->id= $id; $this->listener= $listener; @@ -49,7 +49,7 @@ public function headers() { return $this->headers; } * @return void */ public function open() { - $this->listener->open($this); + $this->listener && $this->listener->open($this); } /** @@ -59,7 +59,7 @@ public function open() { * @return var */ public function on($payload) { - return $this->listener->message($this, $payload); + return $this->listener ? $this->listener->message($this, $payload) : null; } /** @@ -68,7 +68,7 @@ public function on($payload) { * @return void */ public function close() { - $this->listener->close($this); + $this->listener && $this->listener->close($this); } /** From bbbf8104ba2f5d80c5326cabd7f954ffbe8a4070 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Oct 2024 09:57:05 +0200 Subject: [PATCH 14/24] Test path() accessor --- src/main/php/websocket/WebSocket.class.php | 2 +- src/test/php/websocket/unittest/WebSocketTest.class.php | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/php/websocket/WebSocket.class.php b/src/main/php/websocket/WebSocket.class.php index 3c9db41..78f4ad0 100755 --- a/src/main/php/websocket/WebSocket.class.php +++ b/src/main/php/websocket/WebSocket.class.php @@ -45,7 +45,7 @@ public function path() { return $this->path; } /** @return bool */ public function connected() { return $this->socket->isConnected(); } - /** @return ?peer.Socket */ + /** @return peer.Socket */ public function socket() { return $this->socket; } /** @param function(int): string */ diff --git a/src/test/php/websocket/unittest/WebSocketTest.class.php b/src/test/php/websocket/unittest/WebSocketTest.class.php index a789778..f4f64e3 100755 --- a/src/test/php/websocket/unittest/WebSocketTest.class.php +++ b/src/test/php/websocket/unittest/WebSocketTest.class.php @@ -21,6 +21,11 @@ private function fixture($payload= '') { return $fixture; } + #[Test, Values([['ws://example.com', '/'], ['ws://example.com/', '/'], ['ws://example.com/sub', '/sub']])] + public function path($url, $expected) { + Assert::equals($expected, (new WebSocket($url))->path()); + } + #[Test] public function socket_argument() { $s= new Socket('example.com', 8443); From 6addec8bc590448a7424322fccafed872b4e930d Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Oct 2024 09:58:41 +0200 Subject: [PATCH 15/24] Test origin() accessor --- src/main/php/websocket/WebSocket.class.php | 9 ++++++--- .../php/websocket/unittest/WebSocketTest.class.php | 10 ++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/main/php/websocket/WebSocket.class.php b/src/main/php/websocket/WebSocket.class.php index 78f4ad0..3e9cbe8 100755 --- a/src/main/php/websocket/WebSocket.class.php +++ b/src/main/php/websocket/WebSocket.class.php @@ -39,15 +39,18 @@ public function __construct($endpoint, $origin= 'localhost') { $this->origin= $origin; } + /** @return peer.Socket */ + public function socket() { return $this->socket; } + /** @return string */ public function path() { return $this->path; } + /** @return string */ + public function origin() { return $this->origin; } + /** @return bool */ public function connected() { return $this->socket->isConnected(); } - /** @return peer.Socket */ - public function socket() { return $this->socket; } - /** @param function(int): string */ public function random($function) { $this->random= $function; diff --git a/src/test/php/websocket/unittest/WebSocketTest.class.php b/src/test/php/websocket/unittest/WebSocketTest.class.php index f4f64e3..eee2561 100755 --- a/src/test/php/websocket/unittest/WebSocketTest.class.php +++ b/src/test/php/websocket/unittest/WebSocketTest.class.php @@ -26,6 +26,16 @@ public function path($url, $expected) { Assert::equals($expected, (new WebSocket($url))->path()); } + #[Test] + public function default_origin() { + Assert::equals('localhost', (new WebSocket('ws://example.com'))->origin()); + } + + #[Test] + public function origin() { + Assert::equals('example.com', (new WebSocket('ws://example.com', 'example.com'))->origin()); + } + #[Test] public function socket_argument() { $s= new Socket('example.com', 8443); From 57cdbfb096952a4a162202b4bab63664b4df1188 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Oct 2024 10:03:35 +0200 Subject: [PATCH 16/24] Test listener integration --- .../unittest/WebSocketTest.class.php | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/test/php/websocket/unittest/WebSocketTest.class.php b/src/test/php/websocket/unittest/WebSocketTest.class.php index eee2561..7d4a499 100755 --- a/src/test/php/websocket/unittest/WebSocketTest.class.php +++ b/src/test/php/websocket/unittest/WebSocketTest.class.php @@ -3,7 +3,7 @@ use peer\{Socket, ProtocolException}; use test\{Assert, Expect, Test, Values}; use util\Bytes; -use websocket\WebSocket; +use websocket\{WebSocket, Listener}; class WebSocketTest { @@ -173,4 +173,21 @@ public function pings_are_answered() { $fixture->socket()->out ); } + + #[Test] + public function listening() { + $listener= new class() extends Listener { + public $events= []; + public function open($conn) { $this->events[]= 'open'; } + public function message($conn, $message) { $this->events[]= "message<{$message}>"; } + public function close($conn) { $this->events[]= 'close'; } + }; + + $fixture= $this->fixture("\x81\x04Test")->listening($listener); + $fixture->connect(); + iterator_to_array($fixture->receive()); + $fixture->close(); + + Assert::equals(['open', 'message', 'close'], $listener->events); + } } \ No newline at end of file From 06527ab20861ff28347963d9bd2d8ab062a9edfe Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Oct 2024 10:16:05 +0200 Subject: [PATCH 17/24] Add close code and reason to Listener --- src/main/php/websocket/Listener.class.php | 4 +++- src/main/php/websocket/WebSocket.class.php | 14 +++++++------- .../websocket/protocol/Connection.class.php | 9 +++++++-- .../php/websocket/protocol/Messages.class.php | 18 +++++++++--------- .../websocket/unittest/WebSocketTest.class.php | 4 ++-- 5 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/main/php/websocket/Listener.class.php b/src/main/php/websocket/Listener.class.php index b100d90..aaece96 100755 --- a/src/main/php/websocket/Listener.class.php +++ b/src/main/php/websocket/Listener.class.php @@ -23,7 +23,9 @@ public abstract function message($connection, $message); * Closes connection * * @param websocket.protocol.Connection $connection + * @param int $code + * @param string $reason * @return void */ - public function close($connection) { /* NOOP */ } + public function close($connection, $code, $reason) { /* NOOP */ } } \ No newline at end of file diff --git a/src/main/php/websocket/WebSocket.class.php b/src/main/php/websocket/WebSocket.class.php index 3e9cbe8..d6b94fa 100755 --- a/src/main/php/websocket/WebSocket.class.php +++ b/src/main/php/websocket/WebSocket.class.php @@ -186,13 +186,13 @@ public function receive($timeout= null) { break; case Opcodes::CLOSE: - $close= unpack('ncode/a*message', $packet); - $this->conn->close($close['code'], $close['message']); + $close= unpack('ncode/a*reason', $packet); + $this->conn->close($close['code'], $close['reason']); $this->socket->close(); // 1000 is a normal close, all others indicate an error if (1000 === $close['code']) return; - throw new ProtocolException('Connection closed (#'.$close['code'].'): '.$close['message']); + throw new ProtocolException('Connection closed (#'.$close['code'].'): '.$close['reason']); } } } @@ -201,18 +201,18 @@ public function receive($timeout= null) { * Closes connection * * @param int $code - * @param string $message + * @param string $reason * @return void */ - public function close($code= 1000, $message= '') { + public function close($code= 1000, $reason= '') { if (!$this->socket->isConnected()) return; try { - $this->conn->message(Opcodes::CLOSE, pack('n', $code).$message, ($this->random)(4)); + $this->conn->message(Opcodes::CLOSE, pack('na*', $code, $reason), ($this->random)(4)); } catch (Throwable $ignored) { // ... } - $this->conn->close($code, $message); + $this->conn->close($code, $reason); $this->socket->close(); } diff --git a/src/main/php/websocket/protocol/Connection.class.php b/src/main/php/websocket/protocol/Connection.class.php index 31290a8..291ac1e 100755 --- a/src/main/php/websocket/protocol/Connection.class.php +++ b/src/main/php/websocket/protocol/Connection.class.php @@ -65,10 +65,15 @@ public function on($payload) { /** * Closes connection * + * @param int $code + * @param string $reason * @return void */ - public function close() { - $this->listener && $this->listener->close($this); + public function close($code= 1000, $reason= '') { + if ($this->socket->isConnected()) { + $this->listener && $this->listener->close($this, $code, $reason); + $this->socket->close(); + } } /** diff --git a/src/main/php/websocket/protocol/Messages.class.php b/src/main/php/websocket/protocol/Messages.class.php index 821e588..253fb4b 100755 --- a/src/main/php/websocket/protocol/Messages.class.php +++ b/src/main/php/websocket/protocol/Messages.class.php @@ -48,21 +48,21 @@ public function next($socket, $i) { case Opcodes::CLOSE: // Close connection if ('' === $payload) { - $close= ['code' => 1000]; + $close= ['code' => 1000, 'reason' => '']; } else { - $close= unpack('ncode/a*message', $payload); - if (!preg_match('//u', $close['message'])) { - $close= ['code' => 1007]; + $close= unpack('ncode/a*reason', $payload); + if (!preg_match('//u', $close['reason'])) { + $close= ['code' => 1007, 'reason' => '']; } else if ($close['code'] > 2999 || in_array($close['code'], [1000, 1001, 1002, 1003, 1007, 1008, 1009, 1010, 1011])) { - // Answer with client code and message + // Answer with client code and reason } else { - $close= ['code' => 1002]; + $close= ['code' => 1002, 'reason' => '']; } } - $conn->answer(Opcodes::CLOSE, pack('na*', $close['code'], $close['message'] ?? '')); - $this->logging->log($i, 'CLOSE', $close); - $socket->close(); + $conn->answer(Opcodes::CLOSE, pack('na*', $close['code'], $close['reason'])); + $this->logging->log($i, 'CLOSE', $close['code'].($close['reason'] ? ': '.$close['reason'] : '')); + $this->listeners->connections[$i]->close($close['code'], $close['reason']); break; } } catch (Throwable $t) { diff --git a/src/test/php/websocket/unittest/WebSocketTest.class.php b/src/test/php/websocket/unittest/WebSocketTest.class.php index 7d4a499..aa52bae 100755 --- a/src/test/php/websocket/unittest/WebSocketTest.class.php +++ b/src/test/php/websocket/unittest/WebSocketTest.class.php @@ -180,7 +180,7 @@ public function listening() { public $events= []; public function open($conn) { $this->events[]= 'open'; } public function message($conn, $message) { $this->events[]= "message<{$message}>"; } - public function close($conn) { $this->events[]= 'close'; } + public function close($conn, $code, $reason) { $this->events[]= "close<{$code}>"; } }; $fixture= $this->fixture("\x81\x04Test")->listening($listener); @@ -188,6 +188,6 @@ public function close($conn) { $this->events[]= 'close'; } iterator_to_array($fixture->receive()); $fixture->close(); - Assert::equals(['open', 'message', 'close'], $listener->events); + Assert::equals(['open', 'message', 'close<1000>'], $listener->events); } } \ No newline at end of file From 85547a20f81b8e8ba197aaf4dfd8810e8e37ed19 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Oct 2024 10:21:39 +0200 Subject: [PATCH 18/24] QA: WS --- src/main/php/websocket/protocol/Connection.class.php | 10 +++++----- src/main/php/websocket/protocol/Opcodes.class.php | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/php/websocket/protocol/Connection.class.php b/src/main/php/websocket/protocol/Connection.class.php index 291ac1e..ba9f8a1 100755 --- a/src/main/php/websocket/protocol/Connection.class.php +++ b/src/main/php/websocket/protocol/Connection.class.php @@ -97,11 +97,11 @@ private function read($length) { */ public function receive() { $packets= [ - Opcodes::TEXT => '', - Opcodes::BINARY => '', - Opcodes::CLOSE => '', - Opcodes::PING => '', - Opcodes::PONG => '', + Opcodes::TEXT => '', + Opcodes::BINARY => '', + Opcodes::CLOSE => '', + Opcodes::PING => '', + Opcodes::PONG => '', ]; $continue= []; diff --git a/src/main/php/websocket/protocol/Opcodes.class.php b/src/main/php/websocket/protocol/Opcodes.class.php index 12e63ef..497fdc4 100755 --- a/src/main/php/websocket/protocol/Opcodes.class.php +++ b/src/main/php/websocket/protocol/Opcodes.class.php @@ -7,11 +7,11 @@ * @test web.unittest.protocol.OpcodesTest */ class Opcodes { - const TEXT = "\x01"; - const BINARY= "\x02"; - const CLOSE = "\x08"; - const PING = "\x09"; - const PONG = "\x0a"; + const TEXT = "\x01"; + const BINARY = "\x02"; + const CLOSE = "\x08"; + const PING = "\x09"; + const PONG = "\x0a"; /** * Returns an opcode name for a given opcode From 5fa2eb6f9eb9404dfc497181a33522d4b6b6a6da Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Oct 2024 10:34:43 +0200 Subject: [PATCH 19/24] Change receive to return a single message --- src/main/php/websocket/WebSocket.class.php | 21 +++++++++++-------- .../unittest/WebSocketTest.class.php | 10 ++++----- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/main/php/websocket/WebSocket.class.php b/src/main/php/websocket/WebSocket.class.php index d6b94fa..1d49c8d 100755 --- a/src/main/php/websocket/WebSocket.class.php +++ b/src/main/php/websocket/WebSocket.class.php @@ -156,26 +156,28 @@ public function send($message) { } /** - * Receive messages, handling PING and CLOSE + * Receive message, handling PING and CLOSE * - * @return iterable + * @param ?int|float $timeout + * @return ?string|util.Bytes * @throws peer.ProtocolException */ public function receive($timeout= null) { if (!$this->socket->isConnected()) throw new ProtocolException('Not connected'); - if (null !== $timeout && !$this->socket->canRead($timeout)) return; + if (null !== $timeout && !$this->socket->canRead($timeout)) return null; + + $result= null; foreach ($this->conn->receive() as $opcode => $packet) { switch ($opcode) { case Opcodes::TEXT: - $this->conn->on($packet); - yield $packet; + $result= $packet; + $this->conn->on($result); break; case Opcodes::BINARY: - $message= new Bytes($packet); - $this->conn->on($message); - yield $message; + $result= new Bytes($packet); + $this->conn->on($result); break; case Opcodes::PING: @@ -191,10 +193,11 @@ public function receive($timeout= null) { $this->socket->close(); // 1000 is a normal close, all others indicate an error - if (1000 === $close['code']) return; + if (1000 === $close['code']) return null; throw new ProtocolException('Connection closed (#'.$close['code'].'): '.$close['reason']); } } + return $result; } /** diff --git a/src/test/php/websocket/unittest/WebSocketTest.class.php b/src/test/php/websocket/unittest/WebSocketTest.class.php index aa52bae..e12a8cf 100755 --- a/src/test/php/websocket/unittest/WebSocketTest.class.php +++ b/src/test/php/websocket/unittest/WebSocketTest.class.php @@ -97,7 +97,7 @@ public function receive_text() { $fixture= $this->fixture("\x81\x04Test"); $fixture->connect(); - Assert::equals(['Test'], iterator_to_array($fixture->receive())); + Assert::equals('Test', $fixture->receive()); } #[Test] @@ -105,7 +105,7 @@ public function receive_binary() { $fixture= $this->fixture("\x82\x08GIF89..."); $fixture->connect(); - Assert::equals([new Bytes('GIF89...')], iterator_to_array($fixture->receive())); + Assert::equals(new Bytes('GIF89...'), $fixture->receive()); } #[Test] @@ -113,7 +113,7 @@ public function handle_graceful_server_close() { $fixture= $this->fixture("\x88\x02\x03\xe8"); $fixture->connect(); - Assert::equals([], iterator_to_array($fixture->receive())); + Assert::null($fixture->receive()); Assert::false($fixture->connected()); } @@ -160,7 +160,7 @@ public function pings_are_answered() { $fixture= $this->fixture("\x89\x01!"); $fixture->connect(); - Assert::equals([], iterator_to_array($fixture->receive())); + Assert::null($fixture->receive()); Assert::equals( "GET / HTTP/1.1\r\n". "Upgrade: websocket\r\n". @@ -185,7 +185,7 @@ public function close($conn, $code, $reason) { $this->events[]= "close<{$code}>" $fixture= $this->fixture("\x81\x04Test")->listening($listener); $fixture->connect(); - iterator_to_array($fixture->receive()); + $fixture->receive(); $fixture->close(); Assert::equals(['open', 'message', 'close<1000>'], $listener->events); From 0bfa2b1910f43fee1fa34547e889baba2ed2a58b Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Oct 2024 10:37:11 +0200 Subject: [PATCH 20/24] Remove superfluous close() call, the connection does this for us --- src/main/php/websocket/WebSocket.class.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/php/websocket/WebSocket.class.php b/src/main/php/websocket/WebSocket.class.php index 1d49c8d..c0dba35 100755 --- a/src/main/php/websocket/WebSocket.class.php +++ b/src/main/php/websocket/WebSocket.class.php @@ -190,7 +190,6 @@ public function receive($timeout= null) { case Opcodes::CLOSE: $close= unpack('ncode/a*reason', $packet); $this->conn->close($close['code'], $close['reason']); - $this->socket->close(); // 1000 is a normal close, all others indicate an error if (1000 === $close['code']) return null; From 054f1cc1924be598ecb9d09876b3a4b1de853f79 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Oct 2024 10:45:00 +0200 Subject: [PATCH 21/24] Add tests for server errors --- .../php/websocket/unittest/WebSocketTest.class.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/test/php/websocket/unittest/WebSocketTest.class.php b/src/test/php/websocket/unittest/WebSocketTest.class.php index e12a8cf..041a39f 100755 --- a/src/test/php/websocket/unittest/WebSocketTest.class.php +++ b/src/test/php/websocket/unittest/WebSocketTest.class.php @@ -117,6 +117,17 @@ public function handle_graceful_server_close() { Assert::false($fixture->connected()); } + #[Test] + public function handle_server_error() { + $fixture= $this->fixture("\x88\x02\x03\xea"); + $fixture->connect(); + + Assert::throws(ProtocolException::class, function() use($fixture) { + $fixture->receive(); + }); + Assert::false($fixture->connected()); + } + #[Test] public function send_text() { $fixture= $this->fixture(); From f6daa848bfd2ed5a4e90e77abceea55e581c375a Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Oct 2024 11:11:34 +0200 Subject: [PATCH 22/24] Lowercase headers --- src/main/php/websocket/WebSocket.class.php | 4 ++-- .../php/websocket/protocol/Handshake.class.php | 8 ++++---- .../websocket/unittest/WebSocketTest.class.php | 17 +++++++++++++++-- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/main/php/websocket/WebSocket.class.php b/src/main/php/websocket/WebSocket.class.php index c0dba35..d94851d 100755 --- a/src/main/php/websocket/WebSocket.class.php +++ b/src/main/php/websocket/WebSocket.class.php @@ -104,10 +104,10 @@ public function connect($headers= []) { $headers= []; while ($line= $this->socket->readLine()) { sscanf($line, "%[^:]: %[^\r]", $header, $value); - $headers[$header][]= $value; + $headers[strtolower($header)][]= $value; } - $accept= $headers['Sec-Websocket-Accept'][0] ?? ''; + $accept= $headers['sec-websocket-accept'][0] ?? ''; $expect= base64_encode(sha1($key.Handshake::GUID, true)); if ($accept !== $expect) { $this->socket->close(); diff --git a/src/main/php/websocket/protocol/Handshake.class.php b/src/main/php/websocket/protocol/Handshake.class.php index ca423e9..01009e5 100755 --- a/src/main/php/websocket/protocol/Handshake.class.php +++ b/src/main/php/websocket/protocol/Handshake.class.php @@ -25,12 +25,12 @@ public function next($socket, $i) { $headers= []; while ($line= $socket->readLine()) { sscanf($line, "%[^:]: %[^\r]", $header, $value); - $headers[$header][]= $value; + $headers[strtolower($header)][]= $value; } $date= gmdate('D, d M Y H:i:s T'); - $host= isset($headers['Host']) ? $headers['Host'][0] : $socket->localEndpoint()->getAddress(); - $version= isset($headers['Sec-WebSocket-Version']) ? $headers['Sec-WebSocket-Version'][0] : -1; + $host= $headers['host'][0] ?? $socket->localEndpoint()->getAddress(); + $version= $headers['sec-websocket-version'][0] ?? -1; switch ($version) { case 13: if (null === ($listener= $this->listeners->listener($path))) { @@ -53,7 +53,7 @@ public function next($socket, $i) { } // Hash websocket key and well-known GUID - $key= $headers['Sec-WebSocket-Key'][0]; + $key= $headers['sec-websocket-key'][0]; $accept= base64_encode(sha1($key.self::GUID, true)); $socket->write(sprintf( "HTTP/1.1 101 Switching Protocols\r\n". diff --git a/src/test/php/websocket/unittest/WebSocketTest.class.php b/src/test/php/websocket/unittest/WebSocketTest.class.php index 041a39f..8752fa0 100755 --- a/src/test/php/websocket/unittest/WebSocketTest.class.php +++ b/src/test/php/websocket/unittest/WebSocketTest.class.php @@ -13,7 +13,7 @@ private function fixture($payload= '') { "HTTP/1.1 101 Switching Protocols\r\n". "Connection: Upgrade\r\n". "Upgrade: websocket\r\n". - "Sec-Websocket-Accept: pT25h6EVFbWDyyinkmTBvzUVxQo=\r\n". + "Sec-WebSocket-Accept: pT25h6EVFbWDyyinkmTBvzUVxQo=\r\n". "\r\n". $payload )); @@ -69,12 +69,25 @@ public function handshake_mismatch() { "HTTP/1.1 101 Switching Protocols\r\n". "Connection: Upgrade\r\n". "Upgrade: websocket\r\n". - "Sec-Websocket-Accept: EGUNIQA7j7p+kiqxH/TKPdu8A4g=\r\n". + "Sec-WebSocket-Accept: EGUNIQA7j7p+kiqxH/TKPdu8A4g=\r\n". "\r\n" )); $fixture->connect(); } + #[Test] + public function lowercase_headers() { + $fixture= new WebSocket(new Channel( + "HTTP/1.1 101 Switching Protocols\r\n". + "connection: Upgrade\r\n". + "upgrade: websocket\r\n". + "sec-websocket-accept: pT25h6EVFbWDyyinkmTBvzUVxQo=\r\n". + "\r\n" + )); + $fixture->random(function($bytes) { return str_repeat('*', $bytes); }); + $fixture->connect(); + } + #[Test] public function connect() { $fixture= $this->fixture(); From 4ea9432b2aff1b4fce5bc4888d01819202c3fc5c Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Oct 2024 11:24:27 +0200 Subject: [PATCH 23/24] Include body in unexpected response error --- src/main/php/websocket/WebSocket.class.php | 11 ++++++----- .../php/websocket/unittest/WebSocketTest.class.php | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/php/websocket/WebSocket.class.php b/src/main/php/websocket/WebSocket.class.php index d94851d..123ecb7 100755 --- a/src/main/php/websocket/WebSocket.class.php +++ b/src/main/php/websocket/WebSocket.class.php @@ -96,17 +96,18 @@ public function connect($headers= []) { $this->socket->write("\r\n"); sscanf($this->socket->readLine(), "HTTP/%s %d %[^\r]", $version, $status, $message); - if (101 !== $status) { - $this->socket->close(); - throw new ProtocolException('Unexpected response '.$status.' '.$message); - } - $headers= []; while ($line= $this->socket->readLine()) { sscanf($line, "%[^:]: %[^\r]", $header, $value); $headers[strtolower($header)][]= $value; } + if (101 !== $status) { + $body= ($length= $headers['content-length'][0] ?? 0) ? $this->socket->readBinary($length) : ''; + $this->socket->close(); + throw new ProtocolException('Unexpected response '.$status.' '.$message.($body ? ': '.$body : '')); + } + $accept= $headers['sec-websocket-accept'][0] ?? ''; $expect= base64_encode(sha1($key.Handshake::GUID, true)); if ($accept !== $expect) { diff --git a/src/test/php/websocket/unittest/WebSocketTest.class.php b/src/test/php/websocket/unittest/WebSocketTest.class.php index 8752fa0..40ca3e3 100755 --- a/src/test/php/websocket/unittest/WebSocketTest.class.php +++ b/src/test/php/websocket/unittest/WebSocketTest.class.php @@ -52,7 +52,7 @@ public function port($url, $expected) { Assert::equals($expected, (new WebSocket($url))->socket()->port); } - #[Test, Expect(class: ProtocolException::class, message: 'Unexpected response 400 Bad Request')] + #[Test, Expect(class: ProtocolException::class, message: 'Unexpected response 400 Bad Request: No websocket here!')] public function no_websocket_to_connect_to() { $fixture= new WebSocket(new Channel( "HTTP/1.1 400 Bad Request\r\n". From 7565e5fce708a81bab467cfadbf977c2fd314268 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Oct 2024 11:24:35 +0200 Subject: [PATCH 24/24] Add client-side code --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 409cc5a..b0390d2 100755 --- a/README.md +++ b/README.md @@ -35,6 +35,19 @@ Serving Imitator(dev)[] > websocket.logging.ToConsole # ... ``` +To connect to this server, use the following: + +```php +use util\cmd\Console; +use websocket\WebSocket; + +$s= new WebSocket('ws://localhost:8081/echo'; +$s->connect(); + +$s->send('Hello'); +Console::writeLine('<<< ', $s->receive()); +``` + On the JavaScript side, open the connection as follows: ```javascript