├── src ├── WebSocketException.php ├── ServerClient.php ├── Client.php ├── Base.php └── Server.php ├── .editorconfig ├── composer.json ├── LICENSE.md ├── CONTRIBUTING.md └── README.md /src/WebSocketException.php: -------------------------------------------------------------------------------- 1 | $headers 13 | * @param string $resource 14 | * @param array $cookies 15 | * @param Server $server 16 | * @return void 17 | */ 18 | public function __construct( 19 | protected mixed $socket, 20 | public readonly array $headers, 21 | public readonly string $resource, 22 | public readonly array $cookies, 23 | protected Server $server 24 | ) { 25 | } 26 | public function send(string $data): bool 27 | { 28 | return $this->server->send($this->socket, $data); 29 | } 30 | public function disconnect(): void 31 | { 32 | $this->server->disconnectClient($this->socket); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vakata/websocket", 3 | "description": "PHP websocket server and client", 4 | "keywords": [ 5 | "vakata", 6 | "websocket" 7 | ], 8 | "homepage": "https://github.com/vakata/websocket", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "vakata", 13 | "email": "github@vakata.com", 14 | "homepage": "http://www.vakata.com", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php" : ">=8.0" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "vakata\\websocket\\": "src" 24 | } 25 | }, 26 | "autoload-dev": { 27 | "psr-4": { 28 | "vakata\\websocket\\test\\": "tests" 29 | } 30 | }, 31 | "scripts": { 32 | "phpstan": "phpstan analyze -l 8 src", 33 | "phpcs": "phpcs --standard=PSR12 --extensions=php --ignore=*/vendor/* ./", 34 | "phpcsfix": "phpcbf --standard=PSR12 --extensions=php --ignore=*/vendor/* ./" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2022 vakata 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/vakata/websocket). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-12 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). 11 | 12 | - **Document any change in behaviour** - Make sure the `README.md` is kept up-to-date. 13 | 14 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 15 | 16 | - **Create feature branches** - Don't ask us to pull from your master branch. 17 | 18 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 19 | 20 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 21 | 22 | 23 | **Happy coding**! 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # websocket 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Software License][ico-license]](LICENSE.md) 5 | 6 | PHP websocket server and client. Supports secure sockets. 7 | 8 | ## Install 9 | 10 | Via Composer 11 | 12 | ``` bash 13 | $ composer require vakata/websocket 14 | ``` 15 | 16 | ## Server usage 17 | 18 | ``` php 19 | // this handler will forward each message to all clients (except the sender) 20 | $server = new \vakata\websocket\Server('ws://127.0.0.1:8080'); 21 | $server->onMessage(function ($sender, $message, $server) { 22 | foreach ($server->getClients() as $client) { 23 | if ($client !== $sender) { 24 | $client->send($message); 25 | } 26 | } 27 | }); 28 | $server->run(); 29 | ``` 30 | 31 | ## Client usage 32 | 33 | ``` php 34 | // this handler will echo each message to standard output 35 | $client = new \vakata\websocket\Client('ws://127.0.0.1:8080'); 36 | $client->onMessage(function ($message, $client) { 37 | echo $message . "\r\n"; 38 | }); 39 | $client->connect(); 40 | ``` 41 | 42 | ## Usage in HTML 43 | 44 | ``` js 45 | var sock = new WebSocket('ws://127.0.0.1:8080/'); 46 | sock.send("TEST"); 47 | ``` 48 | 49 | ## Contributing 50 | 51 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 52 | 53 | ## Security 54 | 55 | If you discover any security related issues, please email github@vakata.com instead of using the issue tracker. 56 | 57 | ## Credits 58 | 59 | - [vakata][link-author] 60 | 61 | ## License 62 | 63 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 64 | 65 | [ico-version]: https://img.shields.io/packagist/v/vakata/websocket.svg?style=flat-square 66 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 67 | 68 | [link-packagist]: https://packagist.org/packages/vakata/websocket 69 | [link-downloads]: https://packagist.org/packages/vakata/websocket 70 | [link-author]: https://github.com/vakata 71 | 72 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | protected array $headers = []; 24 | protected ?Closure $message = null; 25 | protected ?Closure $tick = null; 26 | protected bool $disconnected = true; 27 | 28 | /** 29 | * Create an instance. 30 | * @param string $address address to bind to, defaults to `"ws://127.0.0.1:8080"` 31 | * @param array $headers optional array of headers to pass when connecting 32 | */ 33 | public function __construct(string $address = 'ws://127.0.0.1:8080', array $headers = []) 34 | { 35 | $this->address = $address; 36 | $this->headers = $headers; 37 | } 38 | 39 | protected function preconnect(): void 40 | { 41 | $addr = parse_url($this->address); 42 | if ($addr === false || !isset($addr['host']) || !isset($addr['port'])) { 43 | throw new WebSocketException('Invalid address'); 44 | } 45 | $headers = $this->headers; 46 | $this->socket = fsockopen( 47 | (isset($addr['scheme']) && in_array($addr['scheme'], ['ssl', 'tls', 'wss']) ? 'tls://' : '') . 48 | $addr['host'], 49 | $addr['port'] 50 | ) ?: throw new WebSocketException('Could not connect'); 51 | 52 | $key = $this->generateKey(); 53 | $headers = array_merge( 54 | $this->normalizeHeaders([ 55 | 'Host' => $addr['host'] . ':' . $addr['port'], 56 | 'Connection' => 'Upgrade', 57 | 'Upgrade' => 'websocket', 58 | 'Sec-Websocket-Key' => $key, 59 | 'Sec-Websocket-Version' => '13', 60 | ]), 61 | $this->normalizeHeaders($headers) 62 | ); 63 | $key = $headers['Sec-Websocket-Key']; 64 | foreach ($headers as $name => $value) { 65 | $headers[$name] = $name . ': ' . $value; 66 | } 67 | array_unshift( 68 | $headers, 69 | 'GET ' . (isset($addr['path']) && strlen($addr['path']) ? $addr['path'] : '/') . ' HTTP/1.1' 70 | ); 71 | $this->sendClear($this->socket, implode("\r\n", $headers) . "\r\n"); 72 | 73 | $data = $this->receiveClear($this->socket); 74 | if (!preg_match('(Sec-Websocket-Accept:\s*(.*)$)mUi', $data, $matches)) { 75 | throw new WebSocketException('Bad response'); 76 | } 77 | if (trim($matches[1]) !== base64_encode(pack('H*', sha1($key . self::$magic)))) { 78 | throw new WebSocketException('Bad key'); 79 | } 80 | $this->disconnected = false; 81 | } 82 | 83 | protected function generateKey(): string 84 | { 85 | $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789'; 86 | $key = ''; 87 | $chars_length = strlen($chars); 88 | for ($i = 0; $i < 16; ++$i) { 89 | $key .= $chars[mt_rand(0, $chars_length - 1)]; 90 | } 91 | 92 | return base64_encode($key); 93 | } 94 | /** 95 | * 96 | * @param array $headers 97 | * @return array 98 | */ 99 | protected function normalizeHeaders(array $headers): array 100 | { 101 | $cleaned = []; 102 | foreach ($headers as $name => $value) { 103 | if (strncmp($name, 'HTTP_', 5) === 0) { 104 | $name = substr($name, 5); 105 | } 106 | if ($name !== false) { 107 | $name = str_replace('_', ' ', strtolower($name)); 108 | $name = str_replace('-', ' ', strtolower($name)); 109 | $name = str_replace(' ', '-', ucwords($name)); 110 | $cleaned[$name] = $value; 111 | } 112 | } 113 | 114 | return $cleaned; 115 | } 116 | /** 117 | * Set a callback to execute when a message arrives. 118 | * 119 | * The callable will receive the message string and the server instance. 120 | * @param callable $callback the callback 121 | * @return $this 122 | */ 123 | public function onMessage(callable $callback): static 124 | { 125 | $this->message = Closure::fromCallable($callback); 126 | 127 | return $this; 128 | } 129 | /** 130 | * Set a callback to execute every few milliseconds. 131 | * 132 | * The callable will receive the server instance. If it returns boolean `false` the client will stop listening. 133 | * @param callable $callback the callback 134 | * @return $this 135 | */ 136 | public function onTick(callable $callback): static 137 | { 138 | $this->tick = Closure::fromCallable($callback); 139 | 140 | return $this; 141 | } 142 | /** 143 | * Send a message to the server. 144 | * @param string $data the data to send 145 | * @param string $opcode the data opcode, defaults to `"text"` 146 | * @return bool was the send successful 147 | */ 148 | public function send(string $data, string $opcode = 'text'): bool 149 | { 150 | return $this->_send($this->socket, $data, $opcode, true); 151 | } 152 | public function disconnect(): void 153 | { 154 | $this->disconnected = true; 155 | } 156 | /** 157 | * Start listening. 158 | */ 159 | public function connect(): void 160 | { 161 | if ($this->disconnected) { 162 | $this->preconnect(); 163 | } 164 | while (true) { 165 | if (isset($this->tick)) { 166 | if (call_user_func($this->tick, $this) === false) { 167 | break; 168 | } 169 | } 170 | $changed = [$this->socket]; 171 | $write = []; 172 | $except = []; 173 | if (@stream_select($changed, $write, $except, null) > 0) { 174 | foreach ($changed as $socket) { 175 | try { 176 | $message = $this->receive($socket); 177 | if (isset($this->message)) { 178 | call_user_func($this->message, $message, $this); 179 | } 180 | } catch (WebSocketException $ignore) { 181 | break 2; 182 | } 183 | } 184 | } 185 | if ($this->disconnected) { 186 | break; 187 | } 188 | usleep(5000); 189 | } 190 | $this->disconnected = true; 191 | @fclose($this->socket); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/Base.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | protected static array $opcodes = [ 19 | 'continuation' => 0, 20 | 'text' => 1, 21 | 'binary' => 2, 22 | 'close' => 8, 23 | 'ping' => 9, 24 | 'pong' => 10, 25 | ]; 26 | 27 | /** 28 | * @var int<1, max> 29 | */ 30 | protected static int $fragmentSize = 4096; 31 | 32 | /** 33 | * the magic key used to generate websocket keys (per specs) 34 | * @var string 35 | */ 36 | protected static string $magic = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; 37 | 38 | /** 39 | * Send data to a socket in clear form (basically fwrite) 40 | * @param resource &$socket the socket to write to 41 | * @param string $data the data to send 42 | * @return bool was the send successful 43 | */ 44 | public function sendClear(mixed &$socket, string $data): bool 45 | { 46 | return fwrite($socket, $data) > 0; 47 | } 48 | /** 49 | * Send data to a socket. 50 | * @param resource &$socket the socket to send to 51 | * @param string $data the data to send 52 | * @param string $opcode one of the opcodes (defaults to "text") 53 | * @param boolean $masked should the data be masked (per specs the server should not mask, defaults to false) 54 | * @return bool was the send successful 55 | */ 56 | public function send(mixed &$socket, string $data, string $opcode = 'text', bool $masked = false): bool 57 | { 58 | while (strlen($data)) { 59 | $temp = substr($data, 0, static::$fragmentSize); 60 | $data = substr($data, static::$fragmentSize); 61 | $temp = $this->encode($temp, $opcode, $masked, strlen($data) === 0); 62 | 63 | if (!is_resource($socket) || get_resource_type($socket) !== "stream") { 64 | return false; 65 | } 66 | $meta = stream_get_meta_data($socket); 67 | if ($meta['timed_out']) { 68 | return false; 69 | } 70 | if (fwrite($socket, $temp) === false) { 71 | return false; 72 | } 73 | $opcode = 'continuation'; 74 | } 75 | 76 | return true; 77 | } 78 | /** 79 | * Read clear data from a socket (basically a fread). 80 | * @param resource &$socket the socket to read from 81 | * @return string the data that was read 82 | */ 83 | public function receiveClear(mixed &$socket): string 84 | { 85 | $data = ''; 86 | $read = static::$fragmentSize; 87 | do { 88 | $buff = fread($socket, max(0, $read)); 89 | if ($buff === false) { 90 | return ''; 91 | } 92 | $data .= $buff; 93 | $meta = stream_get_meta_data($socket); 94 | $read = min((int) $meta['unread_bytes'], static::$fragmentSize); 95 | usleep(1000); 96 | } while (!feof($socket) && (int) $meta['unread_bytes'] > 0); 97 | if (strlen($data) === 1) { 98 | $data .= $this->receiveClear($socket); 99 | } 100 | 101 | return $data; 102 | } 103 | /** 104 | * Read data from a socket (in websocket format) 105 | * @param resource &$socket the socket to read from 106 | * @return string the read data (decoded) 107 | */ 108 | public function receive(mixed &$socket, bool $continuation = false): string 109 | { 110 | $data = fread($socket, 2); 111 | if ($data === false) { 112 | throw new WebSocketException('Could not receive data'); 113 | } 114 | if (strlen($data) === 1) { 115 | $data .= fread($socket, 1); 116 | } 117 | if (strlen($data) < 2) { 118 | throw new WebSocketException('Could not receive data'); 119 | } 120 | 121 | $data = array_values(unpack('C*', $data)); 122 | $final = (bool)($data[0] & 0b10000000); // Final fragment marker. 123 | $rsv = $data[0] & 0b01110000; // Unused bits, ignore 124 | $opcode = $data[0] & 0b00001111; 125 | $masked = (bool)($data[1] & 0b10000000); 126 | $length = $data[1] & 0b01111111; 127 | 128 | if ($length > 125) { 129 | $temp = $length === 126 ? fread($socket, 2) : fread($socket, 8); 130 | if ($temp === false) { 131 | throw new WebSocketException('Could not receive data'); 132 | } 133 | $length = $length === 126 ? current(unpack('n', $temp)) : current(unpack('J', $temp)); 134 | } 135 | 136 | $payload = ''; 137 | $mask = ''; 138 | if ($masked) { 139 | $mask = fread($socket, 4); 140 | if ($mask === false) { 141 | throw new WebSocketException('Could not receive mask data'); 142 | } 143 | } 144 | if ($length > 0) { 145 | $temp = ''; 146 | do { 147 | $buff = fread($socket, min($length, static::$fragmentSize, $length - strlen($temp))); 148 | if ($buff === false) { 149 | throw new WebSocketException('Could not receive data'); 150 | } 151 | $temp .= $buff; 152 | } while (strlen($temp) < $length); 153 | if ($masked) { 154 | for ($i = 0; $i < $length; ++$i) { 155 | $payload .= ($temp[$i] ^ $mask[$i % 4]); 156 | } 157 | } else { 158 | $payload = $temp; 159 | } 160 | } 161 | 162 | if ($opcode === static::$opcodes['close']) { 163 | throw new WebSocketException('Client disconnect'); 164 | } 165 | if ($opcode === self::$opcodes['ping']) { 166 | $this->send($socket, $payload, 'pong', $masked); 167 | return $continuation ? $this->receive($socket, true) : '>PING'.chr(0); 168 | } 169 | return $final ? $payload : $payload . $this->receive($socket, true); 170 | } 171 | 172 | protected function encode(mixed $data, mixed $opcode = 'text', bool $masked = true, bool $final = true): string 173 | { 174 | $length = strlen($data); 175 | 176 | $head = ''; 177 | $head .= (bool) $final ? '1' : '0'; 178 | $head .= '000'; 179 | $head .= sprintf('%04b', static::$opcodes[$opcode]); 180 | $head .= (bool) $masked ? '1' : '0'; 181 | if ($length > 65535) { 182 | $head .= decbin(127); 183 | $head .= sprintf('%064b', $length); 184 | } elseif ($length > 125) { 185 | $head .= decbin(126); 186 | $head .= sprintf('%016b', $length); 187 | } else { 188 | $head .= sprintf('%07b', $length); 189 | } 190 | 191 | $frame = ''; 192 | foreach (str_split($head, 8) as $binstr) { 193 | $frame .= chr((int)bindec($binstr)); 194 | } 195 | $mask = ''; 196 | if ($masked) { 197 | for ($i = 0; $i < 4; ++$i) { 198 | $mask .= chr(rand(0, 255)); 199 | } 200 | $frame .= $mask; 201 | } 202 | for ($i = 0; $i < $length; ++$i) { 203 | $frame .= ($masked === true) ? $data[$i] ^ $mask[$i % 4] : $data[$i]; 204 | } 205 | 206 | return $frame; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/Server.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | protected array $sockets = []; 25 | /** 26 | * @var array 27 | */ 28 | protected array $clients = []; 29 | /** 30 | * @var array 31 | */ 32 | protected array $callbacks = []; 33 | protected ?Closure $tick = null; 34 | 35 | /** 36 | * Create an instance. 37 | * @param string $address where to create the server, defaults to "ws://127.0.0.1:8080" 38 | * @param string $cert optional PEM encoded public and private keys to secure the server with (if `wss` is used) 39 | * @param string $pass optional password for the PEM certificate 40 | */ 41 | public function __construct( 42 | string $address = 'ws://127.0.0.1:8080', 43 | string $cert = null, 44 | string $pass = null, 45 | string $private_key = null 46 | ) { 47 | $addr = $this->parseAddress($address); 48 | 49 | $context = stream_context_create(); 50 | if ($cert !== null) { 51 | stream_context_set_option($context, 'ssl', 'allow_self_signed', true); 52 | stream_context_set_option($context, 'ssl', 'verify_peer', false); 53 | stream_context_set_option($context, 'ssl', 'local_cert', $cert); 54 | if ($private_key) { 55 | stream_context_set_option($context, 'ssl', 'local_pk', $private_key); 56 | } 57 | if ($pass !== null) { 58 | stream_context_set_option($context, 'ssl', 'passphrase', $pass); 59 | } 60 | } 61 | 62 | $this->address = $address; 63 | $ern = null; 64 | $ers = null; 65 | $this->server = @stream_socket_server( 66 | (in_array($addr['scheme'], ['wss', 'tls']) ? 'tls' : 'tcp') . '://' . $addr['host'] . ':' . $addr['port'], 67 | $ern, 68 | $ers, 69 | STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, 70 | $context 71 | ) ?: throw new WebSocketException('Could not create server'); 72 | } 73 | /** 74 | * @param string $address 75 | * @return array 76 | * @throws WebSocketException 77 | */ 78 | protected function parseAddress(string $address): array 79 | { 80 | $addr = parse_url($address); 81 | if ($addr === false || !isset($addr['scheme']) || !isset($addr['host']) || !isset($addr['port'])) { 82 | throw new WebSocketException('Invalid address'); 83 | } 84 | return $addr; 85 | } 86 | 87 | /** 88 | * Start processing requests. This method runs in an infinite loop. 89 | */ 90 | public function run(): void 91 | { 92 | $this->sockets[] = $this->server; 93 | while (true) { 94 | if (isset($this->tick)) { 95 | if (call_user_func($this->tick, $this) === false) { 96 | break; 97 | } 98 | } 99 | $changed = $this->sockets; 100 | $write = []; 101 | $except = []; 102 | if (@stream_select($changed, $write, $except, (isset($this->tick) ? 0 : null)) > 0) { 103 | $messages = []; 104 | foreach ($changed as $socket) { 105 | if ($socket === $this->server) { 106 | $temp = stream_socket_accept($this->server); 107 | if ($temp !== false) { 108 | if ($this->connected($temp)) { 109 | if (isset($this->callbacks['connect'])) { 110 | call_user_func($this->callbacks['connect'], $this->clients[(int) $temp], $this); 111 | } 112 | } 113 | } 114 | } else { 115 | try { 116 | $message = $this->receive($socket); 117 | if ($message !== '>PING'.chr(0)) { 118 | $messages[] = [ 119 | 'client' => $this->clients[(int) $socket], 120 | 'message' => $message, 121 | ]; 122 | } 123 | } catch (WebSocketException $e) { 124 | if (isset($this->callbacks['disconnect'])) { 125 | call_user_func($this->callbacks['disconnect'], $this->clients[(int) $socket], $this); 126 | } 127 | $this->disconnectClient($socket); 128 | } 129 | } 130 | } 131 | foreach ($messages as $message) { 132 | if (isset($this->callbacks['message'])) { 133 | call_user_func($this->callbacks['message'], $message['client'], $message['message'], $this); 134 | } 135 | } 136 | } 137 | usleep(5000); 138 | } 139 | } 140 | /** 141 | * Get an array of all connected clients. 142 | * @return array the clients 143 | */ 144 | public function getClients(): array 145 | { 146 | return $this->clients; 147 | } 148 | /** 149 | * Get the server socket. 150 | * @return resource the socket 151 | */ 152 | public function getServer(): mixed 153 | { 154 | return $this->server; 155 | } 156 | /** 157 | * Set a callback to be executed when a client connects, returning `false` will prevent the client from connecting. 158 | * 159 | * The callable will receive: 160 | * - a ServerClient instance 161 | * - the current server instance 162 | * The callable should return `true` if the client should be allowed to connect or `false` otherwise. 163 | * @param callable $callback the callback to execute when a client connects 164 | * @return $this 165 | */ 166 | public function validateClient(callable $callback): static 167 | { 168 | $this->callbacks['validate'] = Closure::fromCallable($callback); 169 | 170 | return $this; 171 | } 172 | /** 173 | * Set a callback to be executed when a client is connected. 174 | * 175 | * The callable will receive: 176 | * - a ServerClient instance 177 | * - the current server instance 178 | * @param callable $callback the callback to execute 179 | * @return $this 180 | */ 181 | public function onConnect(callable $callback): static 182 | { 183 | $this->callbacks['connect'] = Closure::fromCallable($callback); 184 | 185 | return $this; 186 | } 187 | /** 188 | * Set a callback to execute when a client disconnects. 189 | * 190 | * The callable will receive: 191 | * - a ServerClient instance 192 | * - the current server instance 193 | * @param callable $callback the callback 194 | * @return $this 195 | */ 196 | public function onDisconnect(callable $callback): static 197 | { 198 | $this->callbacks['disconnect'] = Closure::fromCallable($callback); 199 | 200 | return $this; 201 | } 202 | /** 203 | * Set a callback to execute when a client sends a message. 204 | * 205 | * The callable will receive: 206 | * - a ServerClient instance 207 | * - the message string 208 | * - the current server instance 209 | * @param callable $callback the callback 210 | * @return $this 211 | */ 212 | public function onMessage(callable $callback): static 213 | { 214 | $this->callbacks['message'] = Closure::fromCallable($callback); 215 | 216 | return $this; 217 | } 218 | /** 219 | * Set a callback to execute every few milliseconds. 220 | * 221 | * The callable will receive the server instance. If it returns boolean `false` the server will stop listening. 222 | * @param callable $callback the callback 223 | * @return $this 224 | */ 225 | public function onTick(callable $callback): static 226 | { 227 | $this->tick = Closure::fromCallable($callback); 228 | 229 | return $this; 230 | } 231 | /** 232 | * connected 233 | * @param resource $socket 234 | * @return bool 235 | */ 236 | protected function connected(mixed &$socket): bool 237 | { 238 | try { 239 | $headers = $this->receiveClear($socket); 240 | if (!strlen($headers)) { 241 | return false; 242 | } 243 | $headers = str_replace(["\r\n", "\n"], ["\n", "\r\n"], $headers); 244 | $headers = array_filter(explode("\r\n", preg_replace("(\r\n\s+)", ' ', $headers) ?? '')); 245 | $request = explode(' ', array_shift($headers) ?? ''); 246 | if (strtoupper($request[0]) !== 'GET') { 247 | $this->sendClear($socket, "HTTP/1.1 405 Method Not Allowed\r\n\r\n"); 248 | return false; 249 | } 250 | $temp = []; 251 | foreach ($headers as $header) { 252 | $header = explode(':', $header, 2); 253 | $temp[trim(strtolower($header[0]))] = trim($header[1]); 254 | } 255 | $headers = $temp; 256 | if ( 257 | !isset($headers['sec-websocket-key']) || 258 | !isset($headers['upgrade']) || 259 | !isset($headers['connection']) || 260 | strtolower($headers['upgrade']) != 'websocket' || 261 | strpos(strtolower($headers['connection']), 'upgrade') === false 262 | ) { 263 | $this->sendClear($socket, "HTTP/1.1 400 Bad Request\r\n\r\n"); 264 | return false; 265 | } 266 | $cookies = []; 267 | if (isset($headers['cookie'])) { 268 | $temp = explode(';', $headers['cookie']); 269 | foreach ($temp as $v) { 270 | if (trim($v) !== '' && strpos($v, '=') !== false) { 271 | $v = explode('=', $v, 2); 272 | $cookies[trim($v[0])] = $v[1]; 273 | } 274 | } 275 | } 276 | $client = new ServerClient($socket, $headers, $request[1], $cookies, $this); 277 | if (isset($this->callbacks['validate']) && !call_user_func($this->callbacks['validate'], $client, $this)) { 278 | $this->sendClear($socket, "HTTP/1.1 400 Bad Request\r\n\r\n"); 279 | @stream_socket_shutdown($socket, STREAM_SHUT_RDWR); 280 | return false; 281 | } 282 | 283 | $response = []; 284 | $response[] = 'HTTP/1.1 101 WebSocket Protocol Handshake'; 285 | $response[] = 'Upgrade: WebSocket'; 286 | $response[] = 'Connection: Upgrade'; 287 | $response[] = 'Sec-WebSocket-Version: 13'; 288 | $response[] = 'Sec-WebSocket-Location: ' . $this->address; 289 | $response[] = 'Sec-WebSocket-Accept: ' . 290 | base64_encode(sha1($headers['sec-websocket-key'] . self::$magic, true)); 291 | if (isset($headers['origin'])) { 292 | $response[] = 'Sec-WebSocket-Origin: ' . $headers['origin']; 293 | } 294 | 295 | $this->sockets[(int) $socket] = $socket; 296 | $this->clients[(int) $socket] = $client; 297 | 298 | return $this->sendClear($socket, implode("\r\n", $response) . "\r\n\r\n"); 299 | } catch (WebSocketException $e) { 300 | return false; 301 | } 302 | } 303 | /** 304 | * disconnectClient 305 | * @param resource $socket 306 | * @return void 307 | */ 308 | public function disconnectClient(mixed &$socket): void 309 | { 310 | @stream_socket_shutdown($socket, STREAM_SHUT_RDWR); 311 | unset($this->clients[(int) $socket], $this->sockets[(int) $socket], $socket); 312 | } 313 | } 314 | --------------------------------------------------------------------------------