<?phpnamespace App\Net;use App\Chat\ChatServer;use App\Chat\Frame;use App\Chat\Session;/** * A chat client over a WebSocket (port 443 through the web server: it passes every firewall). The client is * php-wasm-network (JumPHP's network library, the same in a browser and on a desktop), so the socket speaks the * little of its relay protocol it needs: "hello", a room ("CHAT") where the server is peer 0 and the client peer 1, * binary messages "[peer byte] + bytes" that carry the applet's TCP stream unchanged, pings. */final class WsSession implements Session{ public const ROOM = 'CHAT'; public const MAX_FRAME = 65536 + 16; public string $buffer = ''; private string $in = ''; private string $output = ''; private bool $open = false; private bool $joined = false; private bool $closed = false; private string $ip = ''; private float $pinged; /** @param resource $stream */ public function __construct(public $stream, private readonly string $peer, private readonly ChatServer $chat) { $this->pinged = microtime(true); } public function address(): string { return ($this->ip !== '' ? $this->ip : $this->peer) . ' (ws)'; } public function ip(): string { return $this->ip !== '' ? $this->ip : ClientIp::host($this->peer); } // ---- Session: what the chat sends goes as one binary message from peer 0 --------------------------------- public function send(string $code, array $fields = []): void { if ($this->joined && !$this->closed) { $this->frame(0x2, "\x00" . Frame::encode($code, $fields)); } } public function close(): void { if ($this->closed) { return; } if ($this->open) { $this->frame(0x8, ''); } $this->flush(); $this->closed = true; if (is_resource($this->stream)) { @fclose($this->stream); } } public function isClosed(): bool { return $this->closed; } public function hasOutput(): bool { return $this->output !== '' && !$this->closed; } public function flush(): void { if ($this->closed || $this->output === '') { return; } $written = @fwrite($this->stream, $this->output); if ($written === false) { $this->closed = true; return; } $this->output = (string)substr($this->output, $written); } /** Keeps proxies (Cloudflare drops a WebSocket silent for 100 s) from closing the line. */ public function keepAlive(float $now): void { if ($this->open && $now - $this->pinged > 30) { $this->pinged = $now; $this->frame(0x9, ''); } } // ---- what comes in -------------------------------------------------------------------------------------- public function read(string $data): void { $this->in .= $data; if (!$this->open) { $end = strpos($this->in, "\r\n\r\n"); if ($end === false) { if (strlen($this->in) > 8192) { $this->close(); } return; } $head = substr($this->in, 0, $end); $this->in = (string)substr($this->in, $end + 4); if (!$this->handshake($head)) { return; } } while (!$this->closed && ($message = $this->nextFrame()) !== null) { [$opcode, $payload] = $message; match ($opcode) { 0x1 => $this->control($payload), 0x2 => $this->binary($payload), 0x8 => $this->close(), 0x9 => $this->frame(0xA, $payload), default => null, }; } } private function handshake(string $head): bool { if (!preg_match('/^Sec-WebSocket-Key:\s*(\S+)/mi', $head, $m) || !preg_match('#^GET #', $head)) { // a plain HTTP request (a health check, a browser): a short answer $body = ChatServer::BANNER . "\n"; $this->output .= "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: " . strlen($body) . "\r\nConnection: close\r\n\r\n" . $body; $this->flush(); $this->closed = true; @fclose($this->stream); return false; } $headers = []; foreach (preg_split('/\r\n/', $head) ?: [] as $line) { if (preg_match('/^([A-Za-z0-9-]+):\s*(.*)$/', $line, $h)) { $headers[strtolower($h[1])] = trim($h[2]); } } $this->ip = ClientIp::resolve($this->peer, $headers); $accept = base64_encode(sha1($m[1] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true)); $this->output .= "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: $accept\r\n\r\n"; $this->open = true; $this->text(['t' => 'hello', 'v' => 1]); $this->flush(); return true; } /** create / join / quick: the client enters the chat's "room"; then the chat says its banner. */ private function control(string $json): void { $m = json_decode($json, true); if (!is_array($m)) { return; } switch ($m['t'] ?? '') { case 'create': case 'join': case 'quick': if ($this->joined) { return; } $this->text(['t' => 'room', 'room' => self::ROOM, 'peer' => 1, 'host' => 0, 'peers' => [0, 1], 'watchers' => [], 'watcher' => false, 'meta' => new \stdClass(), 'max' => 2, 'public' => false]); $this->joined = true; $this->chat->connect($this); break; case 'ping': $this->text(['t' => 'pong', 'id' => $m['id'] ?? 0]); break; case 'leave': $this->close(); break; } } /** [to] + the applet's bytes: cut at line ends, as over TCP. */ private function binary(string $payload): void { if (!$this->joined || $payload === '') { return; } $this->buffer .= substr($payload, 1); foreach (Frame::decode($this->buffer) as [$code, $fields]) { if ($this->closed) { return; } $this->chat->receive($this, $code, $fields); } if (strlen($this->buffer) > TcpServer::MAX_LINE) { $this->close(); } } private function text(array $message): void { $this->frame(0x1, (string)json_encode($message)); } private function frame(int $opcode, string $payload): void { $len = strlen($payload); $head = chr(0x80 | $opcode); $head .= $len < 126 ? chr($len) : ($len < 65536 ? chr(126) . pack('n', $len) : chr(127) . pack('J', $len)); $this->output .= $head . $payload; $this->flush(); } /** @return array{0: int, 1: string}|null one whole frame (client frames are masked) */ private function nextFrame(): ?array { $n = strlen($this->in); if ($n < 2) { return null; } $b1 = ord($this->in[0]); $b2 = ord($this->in[1]); $len = $b2 & 0x7F; $pos = 2; if ($len === 126) { if ($n < 4) { return null; } $len = unpack('n', substr($this->in, 2, 2))[1]; $pos = 4; } elseif ($len === 127) { if ($n < 10) { return null; } $len = unpack('J', substr($this->in, 2, 8))[1]; $pos = 10; } if ($len > self::MAX_FRAME) { $this->close(); return null; } $masked = ($b2 & 0x80) !== 0; $mask = ''; if ($masked) { if ($n < $pos + 4) { return null; } $mask = substr($this->in, $pos, 4); $pos += 4; } if ($n < $pos + $len) { return null; } $payload = substr($this->in, $pos, $len); $this->in = (string)substr($this->in, $pos + $len); if ($masked) { for ($i = 0; $i < $len; $i++) { $payload[$i] = $payload[$i] ^ $mask[$i % 4]; } } // fragments are rare here (small frames): a continuation is joined to nothing and ignored return ($b1 & 0x80) ? [$b1 & 0x0F, $payload] : [0, '']; }}