├── LICENSE.md ├── composer.json └── src ├── Client.php ├── Connection.php ├── Constant.php ├── Exception ├── BadOpcodeException.php ├── BadUriException.php ├── ClientException.php ├── CloseException.php ├── ConnectionClosedException.php ├── ConnectionFailureException.php ├── ConnectionLevelInterface.php ├── ConnectionTimeoutException.php ├── Exception.php ├── ExceptionInterface.php ├── HandshakeException.php ├── MessageLevelInterface.php ├── ReconnectException.php └── ServerException.php ├── Frame ├── Frame.php └── FrameHandler.php ├── Http ├── HttpHandler.php ├── Message.php ├── Request.php ├── Response.php └── ServerRequest.php ├── Message ├── Binary.php ├── Close.php ├── Message.php ├── MessageHandler.php ├── Ping.php ├── Pong.php └── Text.php ├── Middleware ├── Callback.php ├── CloseHandler.php ├── CompressionExtension.php ├── CompressionExtension │ ├── CompressorInterface.php │ └── DeflateCompressor.php ├── FollowRedirect.php ├── MiddlewareHandler.php ├── MiddlewareInterface.php ├── PingInterval.php ├── PingResponder.php ├── ProcessHttpIncomingInterface.php ├── ProcessHttpOutgoingInterface.php ├── ProcessHttpStack.php ├── ProcessIncomingInterface.php ├── ProcessOutgoingInterface.php ├── ProcessStack.php ├── ProcessTickInterface.php ├── ProcessTickStack.php └── SubprotocolNegotiation.php ├── Server.php └── Trait ├── ListenerTrait.php ├── LoggerAwareTrait.php ├── OpcodeTrait.php ├── SendMethodsTrait.php └── StringableTrait.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Websocket: License 2 | 3 | Websocket PHP is free software released under the following license: 4 | 5 | [ISC License](http://en.wikipedia.org/wiki/ISC_license) 6 | 7 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without 8 | fee is hereby granted, provided that the above copyright notice and this permission notice appear 9 | in all copies. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS 12 | SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE 13 | AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, 15 | NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 16 | THIS SOFTWARE. 17 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phrity/websocket", 3 | "type": "library", 4 | "description": "WebSocket client and server", 5 | "homepage": "https://phrity.sirn.se/websocket", 6 | "keywords": ["websocket", "client", "server"], 7 | "license": "ISC", 8 | "authors": [ 9 | { 10 | "name": "Fredrik Liljegren" 11 | }, 12 | { 13 | "name": "Sören Jensen", 14 | "email": "sirn@sirn.se", 15 | "homepage": "https://phrity.sirn.se" 16 | } 17 | ], 18 | "autoload": { 19 | "psr-4": { 20 | "WebSocket\\": "src/" 21 | } 22 | }, 23 | "autoload-dev": { 24 | "psr-4": { 25 | "WebSocket\\Test\\": "tests/mock/" 26 | } 27 | }, 28 | "require": { 29 | "php": "^8.1", 30 | "phrity/net-uri": "^2.1", 31 | "phrity/net-stream": "^2.3", 32 | "psr/http-message": "^1.1 | ^2.0", 33 | "psr/log": "^1.0 | ^2.0 | ^3.0" 34 | }, 35 | "require-dev": { 36 | "php-coveralls/php-coveralls": "^2.0", 37 | "phpstan/phpstan": "^2.0", 38 | "phpunit/phpunit": "^10.0 | ^11.0 | ^12.0", 39 | "phrity/net-mock": "^2.3", 40 | "phrity/util-errorhandler": "^1.1", 41 | "squizlabs/php_codesniffer": "^3.5" 42 | }, 43 | "suggests": { 44 | "ext-zlib": "Required for per-message deflate compression" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | */ 57 | use ListenerTrait; 58 | use LoggerAwareTrait; 59 | use SendMethodsTrait; 60 | use StringableTrait; 61 | 62 | // Settings 63 | /** @var int<0, max>|float $timeout */ 64 | private int|float $timeout = 60; 65 | /** @var int<1, max> $frameSize */ 66 | private int $frameSize = 4096; 67 | private bool $persistent = false; 68 | private Context $context; 69 | /** @var array $headers */ 70 | private array $headers = []; 71 | 72 | // Internal resources 73 | private StreamFactory $streamFactory; 74 | private Uri $socketUri; 75 | private Connection|null $connection = null; 76 | /** @var array $middlewares */ 77 | private array $middlewares = []; 78 | private StreamCollection|null $streams = null; 79 | private bool $running = false; 80 | 81 | 82 | /* ---------- Magic methods ------------------------------------------------------------------------------------ */ 83 | 84 | /** 85 | * @param UriInterface|string $uri A ws/wss-URI 86 | */ 87 | public function __construct(UriInterface|string $uri) 88 | { 89 | $this->socketUri = $this->parseUri($uri); 90 | $this->initLogger(); 91 | $this->context = new Context(); 92 | $this->setStreamFactory(new StreamFactory()); 93 | } 94 | 95 | /** 96 | * Get string representation of instance. 97 | * @return string String representation 98 | */ 99 | public function __toString(): string 100 | { 101 | return $this->stringable('%s', $this->connection ? $this->socketUri->__toString() : 'closed'); 102 | } 103 | 104 | 105 | /* ---------- Configuration ------------------------------------------------------------------------------------ */ 106 | 107 | /** 108 | * Set stream factory to use. 109 | * @param StreamFactory $streamFactory 110 | * @return self 111 | */ 112 | public function setStreamFactory(StreamFactory $streamFactory): self 113 | { 114 | $this->streamFactory = $streamFactory; 115 | return $this; 116 | } 117 | 118 | /** 119 | * Set logger. 120 | * @param LoggerInterface $logger Logger implementation 121 | */ 122 | public function setLogger(LoggerInterface $logger): void 123 | { 124 | $this->logger = $logger; 125 | if ($this->connection) { 126 | $this->connection->setLogger($this->logger); 127 | } 128 | } 129 | 130 | /** 131 | * Set timeout. 132 | * @param int<0, max>|float $timeout Timeout in seconds 133 | * @return self 134 | * @throws InvalidArgumentException If invalid timeout provided 135 | */ 136 | public function setTimeout(int|float $timeout): self 137 | { 138 | if ($timeout < 0) { 139 | throw new InvalidArgumentException("Invalid timeout '{$timeout}' provided"); 140 | } 141 | $this->timeout = $timeout; 142 | if ($this->connection) { 143 | $this->connection->setTimeout($timeout); 144 | } 145 | return $this; 146 | } 147 | 148 | /** 149 | * Get timeout. 150 | * @return int<0, max>|float Timeout in seconds 151 | */ 152 | public function getTimeout(): int|float 153 | { 154 | return $this->timeout; 155 | } 156 | 157 | /** 158 | * Set frame size. 159 | * @param int<1, max> $frameSize Max frame payload size in bytes 160 | * @return self 161 | * @throws InvalidArgumentException If invalid frameSize provided 162 | */ 163 | public function setFrameSize(int $frameSize): self 164 | { 165 | if ($frameSize < 1) { 166 | throw new InvalidArgumentException("Invalid frameSize '{$frameSize}' provided"); 167 | } 168 | $this->frameSize = $frameSize; 169 | if ($this->connection) { 170 | $this->connection->setFrameSize($frameSize); 171 | } 172 | return $this; 173 | } 174 | 175 | /** 176 | * Get frame size. 177 | * @return int Frame size in bytes 178 | */ 179 | public function getFrameSize(): int 180 | { 181 | return $this->frameSize; 182 | } 183 | 184 | /** 185 | * Set connection persistence. 186 | * @param bool $persistent True for persistent connection. 187 | * @return self 188 | */ 189 | public function setPersistent(bool $persistent): self 190 | { 191 | $this->persistent = $persistent; 192 | return $this; 193 | } 194 | 195 | /** 196 | * Set stream context. 197 | * @param Context|array $context Context or options as array 198 | * @see https://www.php.net/manual/en/context.php 199 | * @return self 200 | */ 201 | public function setContext(Context|array $context): self 202 | { 203 | if ($context instanceof Context) { 204 | $this->context = $context; 205 | } else { 206 | $this->context->setOptions($context); 207 | } 208 | return $this; 209 | } 210 | 211 | /** 212 | * Get current stream context. 213 | * @return Context 214 | */ 215 | public function getContext(): Context 216 | { 217 | return $this->context; 218 | } 219 | 220 | /** 221 | * Add header for handshake. 222 | * @param string $name Header name 223 | * @param string $content Header content 224 | * @return self 225 | */ 226 | public function addHeader(string $name, string $content): self 227 | { 228 | $this->headers[$name] = $content; 229 | return $this; 230 | } 231 | 232 | /** 233 | * Add a middleware. 234 | * @param MiddlewareInterface $middleware 235 | * @return self 236 | */ 237 | public function addMiddleware(MiddlewareInterface $middleware): self 238 | { 239 | $this->middlewares[] = $middleware; 240 | if ($this->connection) { 241 | $this->connection->addMiddleware($middleware); 242 | } 243 | return $this; 244 | } 245 | 246 | 247 | /* ---------- Messaging operations ----------------------------------------------------------------------------- */ 248 | 249 | /** 250 | * Send message. 251 | * @template T of Message 252 | * @param T $message 253 | * @return T 254 | */ 255 | public function send(Message $message): Message 256 | { 257 | return $this->connection()->pushMessage($message); 258 | } 259 | 260 | /** 261 | * Receive message. 262 | * Note that this operation will block reading. 263 | * @return Message 264 | */ 265 | public function receive(): Message 266 | { 267 | return $this->connection()->pullMessage(); 268 | } 269 | 270 | 271 | /* ---------- Listener operations ------------------------------------------------------------------------------ */ 272 | 273 | /** 274 | * Start client listener. 275 | * @throws Throwable On low level error 276 | */ 277 | public function start(int|float|null $timeout = null): void 278 | { 279 | // Check if running 280 | if ($this->running) { 281 | $this->logger->warning("[client] Client is already running"); 282 | return; 283 | } 284 | $this->running = true; 285 | $this->logger->info("[client] Client is running"); 286 | 287 | $connection = $this->connection(); 288 | /** @var StreamCollection */ 289 | $streams = $this->streams; 290 | 291 | // Run handler 292 | while ($this->running) { 293 | try { 294 | // Get streams with readable content 295 | $readables = $streams->waitRead($timeout ?? $this->timeout); 296 | foreach ($readables as $key => $readable) { 297 | try { 298 | // Read from connection 299 | $message = $connection->pullMessage(); 300 | $this->dispatch($message->getOpcode(), [$this, $connection, $message]); 301 | } catch (MessageLevelInterface $e) { 302 | // Error, but keep connection open 303 | $this->logger->error("[client] {$e->getMessage()}", ['exception' => $e]); 304 | $this->dispatch('error', [$this, $connection, $e]); 305 | } catch (ConnectionLevelInterface $e) { 306 | // Error, disconnect connection 307 | $this->disconnect(); 308 | $this->logger->error("[client] {$e->getMessage()}", ['exception' => $e]); 309 | $this->dispatch('error', [$this, $connection, $e]); 310 | } 311 | } 312 | if (!$connection->isConnected()) { 313 | $this->running = false; 314 | } 315 | $connection->tick(); 316 | $this->dispatch('tick', [$this]); 317 | } catch (ExceptionInterface $e) { 318 | $this->disconnect(); 319 | $this->running = false; 320 | 321 | // Low-level error 322 | $this->logger->error("[client] {$e->getMessage()}", ['exception' => $e]); 323 | $this->dispatch('error', [$this, null, $e]); 324 | } catch (Throwable $e) { 325 | $this->disconnect(); 326 | $this->running = false; 327 | 328 | // Crash it 329 | $this->logger->error("[client] {$e->getMessage()}", ['exception' => $e]); 330 | throw $e; 331 | } 332 | gc_collect_cycles(); // Collect garbage 333 | } 334 | } 335 | 336 | /** 337 | * Stop client listener (resumable). 338 | */ 339 | public function stop(): void 340 | { 341 | $this->running = false; 342 | $this->logger->info("[client] Client is stopped"); 343 | } 344 | 345 | /** 346 | * If client is running (accepting messages). 347 | * @return bool 348 | */ 349 | public function isRunning(): bool 350 | { 351 | return $this->running; 352 | } 353 | 354 | 355 | /* ---------- Connection management ---------------------------------------------------------------------------- */ 356 | 357 | /** 358 | * If Client has active connection. 359 | * @return bool True if active connection. 360 | */ 361 | public function isConnected(): bool 362 | { 363 | return $this->connection && $this->connection->isConnected(); 364 | } 365 | 366 | /** 367 | * If Client is readable. 368 | * @return bool 369 | */ 370 | public function isReadable(): bool 371 | { 372 | return $this->connection && $this->connection->isReadable(); 373 | } 374 | 375 | /** 376 | * If Client is writable. 377 | * @return bool 378 | */ 379 | public function isWritable(): bool 380 | { 381 | return $this->connection && $this->connection->isWritable(); 382 | } 383 | 384 | 385 | /** 386 | * Connect to server and perform upgrade. 387 | * @throws ClientException On failed connection 388 | */ 389 | public function connect(): void 390 | { 391 | $this->disconnect(); 392 | $this->streams = $this->streamFactory->createStreamCollection(); 393 | 394 | $hostUri = (new Uri()) 395 | ->withScheme(match ($this->socketUri->getScheme()) { 396 | 'ws', 'http' => 'tcp', 397 | 'wss', 'https' => 'ssl', 398 | default => throw new ClientException("Invalid socket scheme: {$this->socketUri->getScheme()}") 399 | }) 400 | ->withHost($this->socketUri->getHost(Uri::IDN_ENCODE)) 401 | ->withPort($this->socketUri->getPort(Uri::REQUIRE_PORT)); 402 | 403 | $stream = null; 404 | 405 | try { 406 | $client = $this->streamFactory->createSocketClient($hostUri, $this->context); 407 | $client->setPersistent($this->persistent); 408 | $client->setTimeout($this->timeout); 409 | $stream = $client->connect(); 410 | } catch (Throwable $e) { 411 | $error = "Could not open socket to \"{$hostUri}\": {$e->getMessage()}"; 412 | $this->logger->error("[client] {$error}", ['exception' => $e]); 413 | throw new ClientException($error); 414 | } 415 | $name = $stream->getRemoteName(); 416 | $this->streams->attach($stream, $name); 417 | $this->connection = new Connection($stream, true, false, $hostUri->getScheme() === 'ssl'); 418 | $this->connection->setFrameSize($this->frameSize); 419 | $this->connection->setTimeout($this->timeout); 420 | $this->connection->setLogger($this->logger); 421 | foreach ($this->middlewares as $middleware) { 422 | $this->connection->addMiddleware($middleware); 423 | } 424 | 425 | if (!$this->isConnected()) { 426 | $error = "Invalid stream on \"{$hostUri}\"."; 427 | $this->logger->error("[client] {$error}"); 428 | throw new ClientException($error); 429 | } 430 | try { 431 | if (!$this->persistent || $stream->tell() == 0) { 432 | $response = $this->performHandshake($this->socketUri, $this->connection); 433 | } 434 | } catch (ReconnectException $e) { 435 | $this->logger->info("[client] {$e->getMessage()}", ['exception' => $e]); 436 | if ($uri = $e->getUri()) { 437 | $this->socketUri = $uri; 438 | } 439 | $this->connect(); 440 | return; 441 | } 442 | $this->logger->info("[client] Client connected to {$this->socketUri}"); 443 | $this->dispatch('handshake', [ 444 | $this, 445 | $this->connection, 446 | $this->connection->getHandshakeRequest(), 447 | $this->connection->getHandshakeResponse(), 448 | ]); 449 | $this->dispatch('connect', [$this, $this->connection, $this->connection?->getHandshakeResponse()]); 450 | } 451 | 452 | /** 453 | * Disconnect from server. 454 | */ 455 | public function disconnect(): void 456 | { 457 | if ($this->connection && $this->isConnected()) { 458 | $this->connection->disconnect(); 459 | $this->logger->info('[client] Client disconnected'); 460 | $this->dispatch('disconnect', [$this, $this->connection]); 461 | } 462 | } 463 | 464 | 465 | /* ---------- Connection wrapper methods ----------------------------------------------------------------------- */ 466 | 467 | /** 468 | * Get name of local socket, or null if not connected. 469 | * @return string|null 470 | */ 471 | public function getName(): string|null 472 | { 473 | return $this->isConnected() ? $this->connection?->getName() : null; 474 | } 475 | 476 | /** 477 | * Get name of remote socket, or null if not connected. 478 | * @return string|null 479 | */ 480 | public function getRemoteName(): string|null 481 | { 482 | return $this->isConnected() ? $this->connection?->getRemoteName() : null; 483 | } 484 | 485 | /** 486 | * Get meta value on connection. 487 | * @param string $key Meta key 488 | * @return mixed Meta value 489 | */ 490 | public function getMeta(string $key): mixed 491 | { 492 | return $this->isConnected() ? $this->connection?->getMeta($key) : null; 493 | } 494 | 495 | /** 496 | * Get Response for handshake procedure. 497 | * @return ResponseInterface|null Handshake. 498 | */ 499 | public function getHandshakeResponse(): ResponseInterface|null 500 | { 501 | return $this->connection ? $this->connection->getHandshakeResponse() : null; 502 | } 503 | 504 | 505 | /* ---------- Internal helper methods -------------------------------------------------------------------------- */ 506 | 507 | /** 508 | * Perform upgrade handshake on new connections. 509 | * @throws HandshakeException On failed handshake 510 | * @throws ReconnectException On reconnect/redirect requirement 511 | */ 512 | protected function performHandshake(Uri $uri, Connection $connection): ResponseInterface 513 | { 514 | // Generate the WebSocket key. 515 | $key = $this->generateKey(); 516 | 517 | $request = new Request('GET', $uri); 518 | 519 | $request = $request 520 | ->withHeader('User-Agent', 'websocket-client-php') 521 | ->withHeader('Connection', 'Upgrade') 522 | ->withHeader('Upgrade', 'websocket') 523 | ->withHeader('Sec-WebSocket-Key', $key) 524 | ->withHeader('Sec-WebSocket-Version', '13'); 525 | 526 | // Handle basic authentication. 527 | if ($userinfo = $uri->getUserInfo(Uri::URI_DECODE)) { 528 | $request = $request->withHeader('Authorization', 'Basic ' . base64_encode($userinfo)); 529 | } 530 | 531 | // Add and override with headers. 532 | foreach ($this->headers as $name => $content) { 533 | $request = $request->withHeader($name, $content); 534 | } 535 | 536 | try { 537 | /** @var RequestInterface */ 538 | $request = $connection->pushHttp($request); 539 | /** @var ResponseInterface */ 540 | $response = $connection->pullHttp(); 541 | 542 | if ($response->getStatusCode() != 101) { 543 | throw new HandshakeException("Invalid status code {$response->getStatusCode()}.", $response); 544 | } 545 | 546 | if (empty($response->getHeaderLine('Sec-WebSocket-Accept'))) { 547 | throw new HandshakeException( 548 | "Connection to '{$uri}' failed: Server sent invalid upgrade response.", 549 | $response 550 | ); 551 | } 552 | 553 | $responseKey = trim($response->getHeaderLine('Sec-WebSocket-Accept')); 554 | $expectedKey = base64_encode( 555 | pack('H*', sha1($key . Constant::GUID)) 556 | ); 557 | 558 | if ($responseKey !== $expectedKey) { 559 | throw new HandshakeException("Server sent bad upgrade response.", $response); 560 | } 561 | } catch (HandshakeException $e) { 562 | $this->logger->error("[client] {$e->getMessage()}", ['exception' => $e]); 563 | throw $e; 564 | } 565 | 566 | $this->logger->debug("[client] Handshake on {$uri->getPath()}"); 567 | $connection->setHandshakeRequest($request); 568 | $connection->setHandshakeResponse($response); 569 | 570 | return $response; 571 | } 572 | 573 | /** 574 | * Generate a random string for WebSocket key. 575 | * @return string Random string 576 | */ 577 | protected function generateKey(): string 578 | { 579 | $key = ''; 580 | for ($i = 0; $i < 16; $i++) { 581 | $key .= chr(rand(33, 126)); 582 | } 583 | return base64_encode($key); 584 | } 585 | 586 | /** 587 | * Ensure URI instance to use in client. 588 | * @param UriInterface|string $uri A ws/wss-URI 589 | * @return Uri 590 | * @throws BadUriException On invalid URI 591 | */ 592 | protected function parseUri(UriInterface|string $uri): Uri 593 | { 594 | try { 595 | if ($uri instanceof Uri) { 596 | $uriInstance = $uri; 597 | } elseif ($uri instanceof UriInterface) { 598 | $uriInstance = new Uri("{$uri}"); 599 | } else { 600 | $uriInstance = new Uri($uri); 601 | } 602 | } catch (InvalidArgumentException $e) { 603 | throw new BadUriException("Invalid URI '{$uri}' provided."); 604 | } 605 | 606 | 607 | if (!in_array($uriInstance->getScheme(), ['ws', 'wss'])) { 608 | throw new BadUriException("Invalid URI scheme, must be 'ws' or 'wss'."); 609 | } 610 | if (!$uriInstance->getHost()) { 611 | throw new BadUriException("Invalid URI host."); 612 | } 613 | return $uriInstance; 614 | } 615 | 616 | protected function connection(): Connection 617 | { 618 | if (!$this->isConnected()) { 619 | $this->connect(); 620 | } 621 | /** @var Connection */ 622 | $connection = $this->connection; 623 | return $connection; 624 | } 625 | } 626 | -------------------------------------------------------------------------------- /src/Connection.php: -------------------------------------------------------------------------------- 1 | $frameSize */ 64 | private int $frameSize = 4096; 65 | /** @var int<0, max>|float $timeout */ 66 | private int|float $timeout = 60; 67 | private string $localName; 68 | private string $remoteName; 69 | private RequestInterface|null $handshakeRequest = null; 70 | private ResponseInterface|null $handshakeResponse = null; 71 | /** @var array $meta */ 72 | private array $meta = []; 73 | private bool $closed = false; 74 | 75 | 76 | /* ---------- Magic methods ------------------------------------------------------------------------------------ */ 77 | 78 | public function __construct(SocketStream $stream, bool $pushMasked, bool $pullMaskedRequired, bool $ssl = false) 79 | { 80 | $this->stream = $stream; 81 | $this->httpHandler = new HttpHandler($this->stream, $ssl); 82 | $this->messageHandler = new MessageHandler(new FrameHandler($this->stream, $pushMasked, $pullMaskedRequired)); 83 | $this->middlewareHandler = new MiddlewareHandler($this->messageHandler, $this->httpHandler); 84 | $this->localName = $this->stream->getLocalName() ?? ''; 85 | $this->remoteName = $this->stream->getRemoteName() ?? ''; 86 | $this->initLogger(); 87 | } 88 | 89 | public function __destruct() 90 | { 91 | if (!$this->closed && $this->isConnected()) { 92 | $this->stream->close(); 93 | } 94 | } 95 | 96 | public function __toString(): string 97 | { 98 | return $this->stringable('%s:%s', $this->localName, $this->remoteName); 99 | } 100 | 101 | 102 | /* ---------- Configuration ------------------------------------------------------------------------------------ */ 103 | 104 | /** 105 | * Set logger. 106 | * @param LoggerInterface $logger Logger implementation 107 | */ 108 | public function setLogger(LoggerInterface $logger): void 109 | { 110 | $this->logger = $logger; 111 | $this->httpHandler->setLogger($logger); 112 | $this->messageHandler->setLogger($logger); 113 | $this->middlewareHandler->setLogger($logger); 114 | $this->logger->debug("[connection] Setting logger: " . get_class($logger)); 115 | } 116 | 117 | /** 118 | * Set time out on connection. 119 | * @param int<0, max>|float $timeout Timeout part in seconds 120 | * @return self 121 | */ 122 | public function setTimeout(int|float $timeout): self 123 | { 124 | if ($timeout < 0) { 125 | throw new InvalidArgumentException("Invalid timeout '{$timeout}' provided"); 126 | } 127 | $this->timeout = $timeout; 128 | $this->stream->setTimeout($timeout); 129 | $this->logger->debug("[connection] Setting timeout: {$timeout} seconds"); 130 | return $this; 131 | } 132 | 133 | /** 134 | * Get timeout. 135 | * @return int<0, max>|float Timeout in seconds. 136 | */ 137 | public function getTimeout(): int|float 138 | { 139 | return $this->timeout; 140 | } 141 | 142 | /** 143 | * Set frame size. 144 | * @param int<1, max> $frameSize Frame size in bytes. 145 | * @return self 146 | */ 147 | public function setFrameSize(int $frameSize): self 148 | { 149 | if ($frameSize < 1) { 150 | throw new InvalidArgumentException("Invalid frameSize '{$frameSize}' provided"); 151 | } 152 | $this->frameSize = $frameSize; 153 | return $this; 154 | } 155 | 156 | /** 157 | * Get frame size. 158 | * @return int<1, max> Frame size in bytes 159 | */ 160 | public function getFrameSize(): int 161 | { 162 | return max(1, $this->frameSize); 163 | } 164 | 165 | /** 166 | * Get current stream context. 167 | * @return Context 168 | */ 169 | public function getContext(): Context 170 | { 171 | return $this->stream->getContext(); 172 | } 173 | 174 | /** 175 | * Add a middleware. 176 | * @param MiddlewareInterface $middleware 177 | * @return self 178 | */ 179 | public function addMiddleware(MiddlewareInterface $middleware): self 180 | { 181 | $this->middlewareHandler->add($middleware); 182 | $this->logger->debug("[connection] Added middleware: {$middleware}"); 183 | return $this; 184 | } 185 | 186 | 187 | /* ---------- Connection management ---------------------------------------------------------------------------- */ 188 | 189 | /** 190 | * If connected to stream. 191 | * @return bool 192 | */ 193 | public function isConnected(): bool 194 | { 195 | return $this->stream->isConnected(); 196 | } 197 | 198 | /** 199 | * If connection is readable. 200 | * @return bool 201 | */ 202 | public function isReadable(): bool 203 | { 204 | return $this->stream->isReadable(); 205 | } 206 | 207 | /** 208 | * If connection is writable. 209 | * @return bool 210 | */ 211 | public function isWritable(): bool 212 | { 213 | return $this->stream->isWritable(); 214 | } 215 | 216 | /** 217 | * Close connection stream. 218 | * @return self 219 | */ 220 | public function disconnect(): self 221 | { 222 | $this->logger->info('[connection] Closing connection'); 223 | $this->stream->close(); 224 | $this->closed = true; 225 | return $this; 226 | } 227 | 228 | /** 229 | * Close connection stream reading. 230 | * @return self 231 | */ 232 | public function closeRead(): self 233 | { 234 | $this->logger->info('[connection] Closing further reading'); 235 | $this->stream->closeRead(); 236 | return $this; 237 | } 238 | 239 | /** 240 | * Close connection stream writing. 241 | * @return self 242 | */ 243 | public function closeWrite(): self 244 | { 245 | $this->logger->info('[connection] Closing further writing'); 246 | $this->stream->closeWrite(); 247 | return $this; 248 | } 249 | 250 | 251 | /* ---------- Connection state --------------------------------------------------------------------------------- */ 252 | 253 | /** 254 | * Get name of local socket, or null if not connected. 255 | * @return string|null 256 | */ 257 | public function getName(): string|null 258 | { 259 | return $this->localName; 260 | } 261 | 262 | /** 263 | * Get name of remote socket, or null if not connected. 264 | * @return string|null 265 | */ 266 | public function getRemoteName(): string|null 267 | { 268 | return $this->remoteName; 269 | } 270 | 271 | /** 272 | * Set meta value on connection. 273 | * @param string $key Meta key 274 | * @param mixed $value Meta value 275 | */ 276 | public function setMeta(string $key, mixed $value): void 277 | { 278 | $this->meta[$key] = $value; 279 | } 280 | 281 | /** 282 | * Get meta value on connection. 283 | * @param string $key Meta key 284 | * @return mixed Meta value 285 | */ 286 | public function getMeta(string $key): mixed 287 | { 288 | return $this->meta[$key] ?? null; 289 | } 290 | 291 | /** 292 | * Tick operation on connection. 293 | */ 294 | public function tick(): void 295 | { 296 | $this->middlewareHandler->processTick($this); 297 | } 298 | 299 | 300 | /* ---------- WebSocket Message methods ------------------------------------------------------------------------ */ 301 | 302 | /** 303 | * Send message. 304 | * @template T of Message 305 | * @param T $message 306 | * @return T 307 | */ 308 | public function send(Message $message): Message 309 | { 310 | return $this->pushMessage($message); 311 | } 312 | 313 | /** 314 | * Push a message to stream. 315 | * @template T of Message 316 | * @param T $message 317 | * @return T 318 | */ 319 | public function pushMessage(Message $message): Message 320 | { 321 | try { 322 | return $this->middlewareHandler->processOutgoing($this, $message); 323 | } catch (Throwable $e) { 324 | $this->throwException($e); 325 | } 326 | } 327 | 328 | // Pull a message from stream 329 | public function pullMessage(): Message 330 | { 331 | try { 332 | return $this->middlewareHandler->processIncoming($this); 333 | } catch (Throwable $e) { 334 | $this->throwException($e); 335 | } 336 | } 337 | 338 | 339 | /* ---------- HTTP Message methods ----------------------------------------------------------------------------- */ 340 | 341 | public function pushHttp(MessageInterface $message): MessageInterface 342 | { 343 | try { 344 | return $this->middlewareHandler->processHttpOutgoing($this, $message); 345 | } catch (Throwable $e) { 346 | $this->throwException($e); 347 | } 348 | } 349 | 350 | public function pullHttp(): MessageInterface 351 | { 352 | try { 353 | return $this->middlewareHandler->processHttpIncoming($this); 354 | } catch (Throwable $e) { 355 | $this->throwException($e); 356 | } 357 | } 358 | 359 | public function setHandshakeRequest(RequestInterface $request): self 360 | { 361 | $this->handshakeRequest = $request; 362 | return $this; 363 | } 364 | 365 | public function getHandshakeRequest(): RequestInterface|null 366 | { 367 | return $this->handshakeRequest; 368 | } 369 | 370 | public function setHandshakeResponse(ResponseInterface $response): self 371 | { 372 | $this->handshakeResponse = $response; 373 | return $this; 374 | } 375 | 376 | public function getHandshakeResponse(): ResponseInterface|null 377 | { 378 | return $this->handshakeResponse; 379 | } 380 | 381 | 382 | /* ---------- Internal helper methods -------------------------------------------------------------------------- */ 383 | 384 | protected function throwException(Throwable $e): never 385 | { 386 | // Internal exceptions are handled and re-thrown 387 | if ($e instanceof ReconnectException) { 388 | $this->logger->info("[connection] {$e->getMessage()}", ['exception' => $e]); 389 | throw $e; 390 | } 391 | if ($e instanceof Exception) { 392 | $this->logger->error("[connection] {$e->getMessage()}", ['exception' => $e]); 393 | throw $e; 394 | } 395 | // External exceptions are converted to internal 396 | if ($this->isConnected()) { 397 | $meta = $this->stream->getMetadata(); 398 | $json = json_encode($meta); 399 | if (!empty($meta['timed_out'])) { 400 | $this->logger->error("[connection] {$e->getMessage()}", ['exception' => $e, 'meta' => $meta]); 401 | throw new ConnectionTimeoutException(); 402 | } 403 | if (!empty($meta['eof'])) { 404 | $this->logger->error("[connection] {$e->getMessage()}", ['exception' => $e, 'meta' => $meta]); 405 | throw new ConnectionClosedException(); 406 | } 407 | } 408 | $this->logger->error("[connection] {$e->getMessage()}", ['exception' => $e]); 409 | throw new ConnectionFailureException(); 410 | } 411 | } 412 | -------------------------------------------------------------------------------- /src/Constant.php: -------------------------------------------------------------------------------- 1 | status = $status; 22 | parent::__construct($content); 23 | } 24 | 25 | public function getCloseStatus(): int 26 | { 27 | return $this->status ?? 1000; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Exception/ConnectionClosedException.php: -------------------------------------------------------------------------------- 1 | response = $response; 24 | } 25 | 26 | public function getResponse(): ResponseInterface 27 | { 28 | return $this->response; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Exception/MessageLevelInterface.php: -------------------------------------------------------------------------------- 1 | uri = $uri; 23 | parent::__construct("Reconnect requested" . ($uri ? ": {$uri}" : '')); 24 | } 25 | 26 | public function getUri(): Uri|null 27 | { 28 | return $this->uri; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Exception/ServerException.php: -------------------------------------------------------------------------------- 1 | opcode = $opcode; 37 | $this->payload = $payload; 38 | $this->final = $final; 39 | $this->rsv1 = $rsv1; 40 | $this->rsv2 = $rsv2; 41 | $this->rsv3 = $rsv3; 42 | } 43 | 44 | public function isFinal(): bool 45 | { 46 | return $this->final; 47 | } 48 | 49 | public function getRsv1(): bool 50 | { 51 | return $this->rsv1; 52 | } 53 | 54 | public function setRsv1(bool $rsv1): void 55 | { 56 | $this->rsv1 = $rsv1; 57 | } 58 | 59 | public function getRsv2(): bool 60 | { 61 | return $this->rsv2; 62 | } 63 | 64 | public function getRsv3(): bool 65 | { 66 | return $this->rsv3; 67 | } 68 | 69 | public function isContinuation(): bool 70 | { 71 | return $this->opcode === 'continuation'; 72 | } 73 | 74 | public function getOpcode(): string 75 | { 76 | return $this->opcode; 77 | } 78 | 79 | public function getPayload(): string 80 | { 81 | return $this->payload; 82 | } 83 | 84 | public function getPayloadLength(): int 85 | { 86 | return strlen($this->payload); 87 | } 88 | 89 | public function __toString(): string 90 | { 91 | return $this->stringable('%s', $this->opcode); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Frame/FrameHandler.php: -------------------------------------------------------------------------------- 1 | stream = $stream; 38 | $this->pushMasked = $pushMasked; 39 | $this->pullMaskedRequired = $pullMaskedRequired; 40 | $this->initLogger(); 41 | } 42 | 43 | // Pull frame from stream 44 | public function pull(): Frame 45 | { 46 | // Read the frame "header" first, two bytes. 47 | $data = $this->read(2); 48 | list ($byte1, $byte2) = array_values($this->unpack('C*', $data)); 49 | $final = (bool)($byte1 & 0b10000000); // Final fragment marker. 50 | $rsv1 = (bool)($byte1 & 0b01000000); 51 | $rsv2 = (bool)($byte1 & 0b00100000); 52 | $rsv3 = (bool)($byte1 & 0b00010000); 53 | 54 | // Parse opcode 55 | $opcodeInt = $byte1 & 0b00001111; 56 | $opcodeInts = array_flip(self::$opcodes); 57 | $opcode = array_key_exists($opcodeInt, $opcodeInts) ? $opcodeInts[$opcodeInt] : strval($opcodeInt); 58 | 59 | // Masking bit 60 | $masked = (bool)($byte2 & 0b10000000); 61 | 62 | $payload = ''; 63 | 64 | // Payload length 65 | $payloadLength = $byte2 & 0b01111111; 66 | 67 | if ($payloadLength > 125) { 68 | if ($payloadLength === 126) { 69 | $data = $this->read(2); // 126: Payload length is a 16-bit unsigned int 70 | $payloadLength = current($this->unpack('n', $data)); 71 | } else { 72 | $data = $this->read(8); // 127: Payload length is a 64-bit unsigned int 73 | $payloadLength = current($this->unpack('J', $data)); 74 | } 75 | } 76 | 77 | // Get masking key. 78 | if ($masked) { 79 | $maskingKey = $this->stream->read(4); 80 | } 81 | 82 | // Get the actual payload, if any (might not be for e.g. close frames). 83 | if ($payloadLength > 0) { 84 | $data = $this->read($payloadLength); 85 | if ($masked) { 86 | // Unmask payload. 87 | for ($i = 0; $i < $payloadLength; $i++) { 88 | $payload .= ($data[$i] ^ $maskingKey[$i % 4]); 89 | } 90 | } else { 91 | $payload = $data; 92 | } 93 | } 94 | 95 | $frame = new Frame($opcode, $payload, $final, $rsv1, $rsv2, $rsv3); 96 | $this->logger->debug("[frame-handler] Pulled '{$opcode}' frame", [ 97 | 'opcode' => $frame->getOpcode(), 98 | 'final' => $frame->isFinal(), 99 | 'content-length' => $frame->getPayloadLength(), 100 | ]); 101 | 102 | if ($this->pullMaskedRequired && !$masked) { 103 | $this->logger->error("[frame-handler] Masking required, but frame was unmasked"); 104 | throw new CloseException(1002, 'Masking required'); 105 | } 106 | 107 | return $frame; 108 | } 109 | 110 | // Push frame to stream 111 | public function push(Frame $frame): int 112 | { 113 | $payload = $frame->getPayload(); 114 | $payloadLength = $frame->getPayloadLength(); 115 | 116 | $data = ''; 117 | $byte1 = $frame->isFinal() ? 0b10000000 : 0b00000000; // Final fragment marker. 118 | $byte1 |= $frame->getRsv1() ? 0b01000000 : 0b00000000; // RSV1 bit. 119 | $byte1 |= $frame->getRsv2() ? 0b00100000 : 0b00000000; // RSV2 bit. 120 | $byte1 |= $frame->getRsv3() ? 0b00010000 : 0b00000000; // RSV3 bit. 121 | $byte1 |= self::$opcodes[$frame->getOpcode()]; // Set opcode. 122 | $data .= pack('C', $byte1); 123 | 124 | $byte2 = $this->pushMasked ? 0b10000000 : 0b00000000; // Masking bit marker. 125 | 126 | // 7 bits of payload length 127 | if ($payloadLength > 65535) { 128 | $data .= pack('C', $byte2 | 0b01111111); 129 | $data .= pack('J', $payloadLength); 130 | } elseif ($payloadLength > 125) { 131 | $data .= pack('C', $byte2 | 0b01111110); 132 | $data .= pack('n', $payloadLength); 133 | } else { 134 | $data .= pack('C', $byte2 | $payloadLength); 135 | } 136 | 137 | // Handle masking. 138 | if ($this->pushMasked) { 139 | // Generate a random mask. 140 | $mask = ''; 141 | for ($i = 0; $i < 4; $i++) { 142 | $mask .= chr(rand(0, 255)); 143 | } 144 | $data .= $mask; 145 | 146 | // Append masked payload to frame. 147 | for ($i = 0; $i < $payloadLength; $i++) { 148 | $data .= $payload[$i] ^ $mask[$i % 4]; 149 | } 150 | } else { 151 | // Append payload as-is to frame. 152 | $data .= $payload; 153 | } 154 | 155 | // Write to stream. 156 | $written = $this->write($data); 157 | 158 | $this->logger->debug("[frame-handler] Pushed '{opcode}' frame", [ 159 | 'opcode' => $frame->getOpcode(), 160 | 'final' => $frame->isFinal(), 161 | 'content-length' => $frame->getPayloadLength(), 162 | ]); 163 | return $written; 164 | } 165 | 166 | /** 167 | * Secured read op 168 | * @param int<1, max> $length 169 | */ 170 | private function read(int $length): string 171 | { 172 | $data = ''; 173 | $read = 0; 174 | while ($read < $length) { 175 | /** @var int<1, max> $readLength */ 176 | $readLength = $length - $read; 177 | $got = $this->stream->read($readLength); 178 | if (empty($got)) { 179 | throw new RuntimeException('Empty read; connection dead?'); 180 | } 181 | $data .= $got; 182 | $read = strlen($data); 183 | } 184 | return $data; 185 | } 186 | 187 | // Secured write op 188 | private function write(string $data): int 189 | { 190 | $length = strlen($data); 191 | $written = $this->stream->write($data); 192 | if ($written < $length) { 193 | throw new RuntimeException("Could only write {$written} out of {$length} bytes."); 194 | } 195 | return $written; 196 | } 197 | 198 | /** @return array */ 199 | private function unpack(string $format, string $string): array 200 | { 201 | /** @var array $result */ 202 | $result = unpack($format, $string); 203 | return $result; 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/Http/HttpHandler.php: -------------------------------------------------------------------------------- 1 | stream = $stream; 38 | $this->ssl = $ssl; 39 | } 40 | 41 | /** 42 | * @deprecated Remove in v4 43 | */ 44 | public function setLogger(LoggerInterface $logger): void 45 | { 46 | } 47 | 48 | public function pull(): MessageInterface 49 | { 50 | $status = $this->readLine(); 51 | $path = $version = null; 52 | 53 | // Pulling server request 54 | preg_match('!^(?P[A-Z]+) (?P[^ ]*) HTTP/(?P[0-9/.]+)!', $status, $matches); 55 | if (!empty($matches)) { 56 | $message = new ServerRequest($matches['method']); 57 | $path = $matches['path']; 58 | $version = $matches['version']; 59 | } 60 | 61 | // Pulling response 62 | preg_match('!^HTTP/(?P[0-9/.]+) (?P[0-9]*)($|\s(?P.*))!', $status, $matches); 63 | if (!empty($matches)) { 64 | $message = new Response((int)$matches['code'], $matches['reason'] ?? ''); 65 | $version = $matches['version']; 66 | } 67 | 68 | if (empty($message)) { 69 | throw new RuntimeException('Invalid Http request.'); 70 | } 71 | 72 | if ($version) { 73 | $message = $message->withProtocolVersion($version); 74 | } 75 | 76 | while ($header = $this->readLine()) { 77 | $parts = explode(':', $header, 2); 78 | if (count($parts) == 2) { 79 | if ($message->getheaderLine($parts[0]) === '') { 80 | $message = $message->withHeader($parts[0], trim($parts[1])); 81 | } else { 82 | $message = $message->withAddedHeader($parts[0], trim($parts[1])); 83 | } 84 | } 85 | } 86 | if ($message instanceof Request) { 87 | $scheme = $this->ssl ? 'wss' : 'ws'; 88 | $uri = new Uri("{$scheme}://{$message->getHeaderLine('Host')}{$path}"); 89 | $message = $message->withUri($uri, true); 90 | } 91 | 92 | return $message; 93 | } 94 | 95 | /** 96 | * @param MessageInterface $message 97 | * @return MessageInterface 98 | */ 99 | public function push(MessageInterface $message): MessageInterface 100 | { 101 | if (!$message instanceof Message) { 102 | throw new RuntimeException('Generic MessageInterface currently not supported.'); 103 | } 104 | $data = implode("\r\n", $message->getAsArray()) . "\r\n\r\n"; 105 | $this->stream->write($data); 106 | return $message; 107 | } 108 | 109 | private function readLine(): string 110 | { 111 | $data = ''; 112 | do { 113 | $buffer = $this->stream->readLine(1024); 114 | if (is_null($buffer)) { 115 | throw new RuntimeException('Could not read Http request.'); 116 | } 117 | $data .= $buffer; 118 | } while (!str_ends_with($data, "\n")); 119 | return trim($data); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Http/Message.php: -------------------------------------------------------------------------------- 1 | > $headers */ 29 | private array $headers = []; 30 | 31 | /** 32 | * Retrieves the HTTP protocol version as a string. 33 | * @return string HTTP protocol version. 34 | */ 35 | public function getProtocolVersion(): string 36 | { 37 | return $this->version; 38 | } 39 | 40 | /** 41 | * Return an instance with the specified HTTP protocol version. 42 | * @param string $version HTTP protocol version 43 | * @return static 44 | */ 45 | public function withProtocolVersion(string $version): self 46 | { 47 | $new = clone $this; 48 | $new->version = $version; 49 | return $new; 50 | } 51 | 52 | /** 53 | * Retrieves all message header values. 54 | * @return string[][] Returns an associative array of the message's headers. 55 | */ 56 | public function getHeaders(): array 57 | { 58 | return array_merge(...array_values($this->headers)); 59 | } 60 | 61 | /** 62 | * Checks if a header exists by the given case-insensitive name. 63 | * @param string $name Case-insensitive header field name. 64 | * @return bool Returns true if any header names match the given header. 65 | */ 66 | public function hasHeader(string $name): bool 67 | { 68 | return array_key_exists(strtolower($name), $this->headers); 69 | } 70 | 71 | /** 72 | * Retrieves a message header value by the given case-insensitive name. 73 | * @param string $name Case-insensitive header field name. 74 | * @return string[] An array of string values as provided for the given header. 75 | */ 76 | public function getHeader(string $name): array 77 | { 78 | return $this->hasHeader($name) 79 | ? array_merge(...array_values($this->headers[strtolower($name)] ?: [])) 80 | : []; 81 | } 82 | 83 | /** 84 | * Retrieves a comma-separated string of the values for a single header. 85 | * @param string $name Case-insensitive header field name. 86 | * @return string A string of values as provided for the given header. 87 | */ 88 | public function getHeaderLine(string $name): string 89 | { 90 | return implode(',', $this->getHeader($name)); 91 | } 92 | 93 | /** 94 | * Return an instance with the provided value replacing the specified header. 95 | * @param string $name Case-insensitive header field name. 96 | * @param string|string[] $value Header value(s). 97 | * @return static 98 | * @throws \InvalidArgumentException for invalid header names or values. 99 | */ 100 | public function withHeader(string $name, mixed $value): self 101 | { 102 | $new = clone $this; 103 | $new->removeHeader($name); 104 | $new->handleHeader($name, $value); 105 | return $new; 106 | } 107 | 108 | /** 109 | * Return an instance with the specified header appended with the given value. 110 | * @param string $name Case-insensitive header field name to add. 111 | * @param string|string[] $value Header value(s). 112 | * @return static 113 | * @throws \InvalidArgumentException for invalid header names. 114 | * @throws \InvalidArgumentException for invalid header values. 115 | */ 116 | public function withAddedHeader(string $name, mixed $value): self 117 | { 118 | $new = clone $this; 119 | $new->handleHeader($name, $value); 120 | return $new; 121 | } 122 | 123 | /** 124 | * Return an instance without the specified header. 125 | * @param string $name Case-insensitive header field name to remove. 126 | * @return static 127 | */ 128 | public function withoutHeader(string $name): self 129 | { 130 | $new = clone $this; 131 | $new->removeHeader($name); 132 | return $new; 133 | } 134 | 135 | /** 136 | * Not implemented, WebSocket only use headers. 137 | */ 138 | public function getBody(): StreamInterface 139 | { 140 | throw new BadMethodCallException("Not implemented."); 141 | } 142 | 143 | /** 144 | * Not implemented, WebSocket only use headers. 145 | */ 146 | public function withBody(StreamInterface $body): self 147 | { 148 | throw new BadMethodCallException("Not implemented."); 149 | } 150 | 151 | /** @return array */ 152 | public function getAsArray(): array 153 | { 154 | $lines = []; 155 | foreach ($this->getHeaders() as $name => $values) { 156 | foreach ($values as $value) { 157 | $lines[] = "{$name}: {$value}"; 158 | } 159 | } 160 | return $lines; 161 | } 162 | 163 | protected function handleHeader(string $name, mixed $value): void 164 | { 165 | if (!preg_match('|^[0-9a-zA-Z#_-]+$|', $name)) { 166 | throw new InvalidArgumentException("'{$name}' is not a valid header field name."); 167 | } 168 | $value = is_array($value) ? $value : [$value]; 169 | foreach ($value as $content) { 170 | if (!is_string($content) && !is_numeric($content)) { 171 | throw new InvalidArgumentException("Invalid header value(s) provided."); 172 | } 173 | $this->headers[strtolower($name)][$name][] = trim((string)$content); 174 | } 175 | } 176 | 177 | protected function removeHeader(string $name): void 178 | { 179 | if ($this->hasHeader($name)) { 180 | unset($this->headers[strtolower($name)]); 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/Http/Request.php: -------------------------------------------------------------------------------- 1 | $methods */ 25 | private static array $methods = ['GET', 'HEAD', 'OPTIONS', 'TRACE', 'PUT', 'DELETE', 'POST', 'PATCH', 'CONNECT']; 26 | 27 | private string $target = ''; 28 | private string $method; 29 | private Uri $uri; 30 | 31 | public function __construct(string $method = 'GET', UriInterface|string|null $uri = null) 32 | { 33 | if (!in_array($method, self::$methods)) { 34 | throw new InvalidArgumentException("Invalid method '{$method}' provided."); 35 | } 36 | $this->uri = $uri instanceof Uri ? $uri : new Uri((string)$uri); 37 | $this->method = $method; 38 | $this->handleHeader('Host', $this->formatHostHeader($this->uri)); 39 | } 40 | 41 | /** 42 | * Retrieves the message's request target. 43 | * @return string 44 | */ 45 | public function getRequestTarget(): string 46 | { 47 | if ($this->target) { 48 | return $this->target; 49 | } 50 | $uri = (new Uri())->withPath($this->uri->getPath())->withQuery($this->uri->getQuery()); 51 | return $uri->toString(Uri::ABSOLUTE_PATH); 52 | } 53 | 54 | /** 55 | * Return an instance with the specific request-target. 56 | * @param mixed $requestTarget 57 | * @return static 58 | */ 59 | public function withRequestTarget(mixed $requestTarget): self 60 | { 61 | $new = clone $this; 62 | $new->target = $requestTarget; 63 | return $new; 64 | } 65 | 66 | /** 67 | * Retrieves the HTTP method of the request. 68 | * @return string Returns the request method. 69 | */ 70 | public function getMethod(): string 71 | { 72 | return $this->method; 73 | } 74 | 75 | /** 76 | * Return an instance with the provided HTTP method. 77 | * @param string $method Case-sensitive method. 78 | * @return static 79 | * @throws InvalidArgumentException for invalid HTTP methods. 80 | */ 81 | public function withMethod(string $method): self 82 | { 83 | if (!in_array($method, self::$methods)) { 84 | throw new InvalidArgumentException("Invalid method '{$method}' provided."); 85 | } 86 | $new = clone $this; 87 | $new->method = $method; 88 | return $new; 89 | } 90 | 91 | /** 92 | * Retrieves the URI instance. 93 | * This method MUST return a UriInterface instance. 94 | * @return UriInterface Returns a UriInterface instance representing the URI of the request. 95 | */ 96 | public function getUri(): UriInterface 97 | { 98 | return $this->uri; 99 | } 100 | 101 | /** 102 | * Returns an instance with the provided URI. 103 | * @param UriInterface $uri New request URI to use. 104 | * @param bool $preserveHost Preserve the original state of the Host header. 105 | * @return static 106 | */ 107 | public function withUri(UriInterface $uri, bool $preserveHost = false): self 108 | { 109 | $new = clone $this; 110 | $new->uri = $uri instanceof Uri ? $uri : new Uri((string)$uri); 111 | if (!$preserveHost || !$new->hasHeader('host')) { 112 | $new->removeHeader('host'); 113 | $new->handleHeader('Host', $this->formatHostHeader($uri)); 114 | } 115 | return $new; 116 | } 117 | 118 | public function __toString(): string 119 | { 120 | return $this->stringable('%s %s', $this->getMethod(), $this->getUri()); 121 | } 122 | 123 | /** @return array */ 124 | public function getAsArray(): array 125 | { 126 | return array_merge([ 127 | "{$this->getMethod()} {$this->getRequestTarget()} HTTP/{$this->getProtocolVersion()}", 128 | ], parent::getAsArray()); 129 | } 130 | 131 | private function formatHostHeader(UriInterface $uri): string 132 | { 133 | $host = $uri->getHost(); 134 | $port = $uri->getPort(); 135 | return $host && $port ? "{$host}:{$port}" : $host; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Http/Response.php: -------------------------------------------------------------------------------- 1 | $codes */ 25 | private static array $codes = [ 26 | 100 => 'Continue', 27 | 101 => 'Switching Protocols', 28 | 102 => 'Processing', 29 | 103 => 'Early Hints', 30 | 200 => 'OK', 31 | 201 => 'Created', 32 | 202 => 'Accepted', 33 | 203 => 'Non-Authoritative Information', 34 | 204 => 'No Content', 35 | 205 => 'Reset Content', 36 | 206 => 'Partial Content', 37 | 207 => 'Multi-Status', 38 | 208 => 'Already Reported', 39 | 226 => 'IM Used', 40 | 300 => 'Multiple Choices', 41 | 301 => 'Moved Permanently', 42 | 302 => 'Found', 43 | 303 => 'See Other', 44 | 304 => 'Not Modified', 45 | 305 => 'Use Proxy', 46 | 307 => 'Temporary Redirect', 47 | 308 => 'Permanent Redirect', 48 | 400 => 'Bad Request', 49 | 401 => 'Unauthorized', 50 | 402 => 'Payment Required', 51 | 403 => 'Forbidden', 52 | 404 => 'Not Found', 53 | 405 => 'Method Not Allowed', 54 | 406 => 'Not Acceptable', 55 | 407 => 'Proxy Authentication Required', 56 | 408 => 'Request Timeout', 57 | 409 => 'Conflict', 58 | 410 => 'Gone', 59 | 411 => 'Length Required', 60 | 412 => 'Precondition Failed', 61 | 413 => 'Content Too Large', 62 | 414 => 'URI Too Long', 63 | 415 => 'Unsupported Media Type', 64 | 416 => 'Range Not Satisfiable', 65 | 417 => 'Expectation Failed', 66 | 421 => 'Misdirected Request', 67 | 422 => 'Unprocessable Content', 68 | 423 => 'Locked', 69 | 424 => 'Failed Dependency', 70 | 425 => 'Too Early', 71 | 426 => 'Upgrade Required', 72 | 428 => 'Precondition Required', 73 | 429 => 'Too Many Requests', 74 | 431 => 'Request Header Fields Too Large', 75 | 451 => 'Unavailable For Legal Reasons', 76 | 500 => 'Internal Server Error', 77 | 501 => 'Not Implemented', 78 | 502 => 'Bad Gateway', 79 | 503 => 'Service Unavailable', 80 | 504 => 'Gateway Timeout', 81 | 505 => 'HTTP Version Not Supported', 82 | 506 => 'Variant Also Negotiates', 83 | 507 => 'Insufficient Storage', 84 | 508 => 'Loop Detected', 85 | 510 => 'Not Extended', 86 | 511 => 'Network Authentication Required', 87 | ]; 88 | 89 | private int $code; 90 | private string $reason; 91 | 92 | public function __construct(int $code = 200, string $reasonPhrase = '') 93 | { 94 | if ($code < 100 || $code > 599) { 95 | throw new InvalidArgumentException("Invalid status code '{$code}' provided."); 96 | } 97 | $this->code = $code; 98 | $this->reason = $reasonPhrase; 99 | } 100 | 101 | /** 102 | * Gets the response status code. 103 | * @return int Status code. 104 | */ 105 | public function getStatusCode(): int 106 | { 107 | return $this->code; 108 | } 109 | 110 | /** 111 | * Return an instance with the specified status code and, optionally, reason phrase. 112 | * @param int $code The 3-digit integer result code to set. 113 | * @param string $reasonPhrase The reason phrase to use. 114 | * @return static 115 | * @throws InvalidArgumentException For invalid status code arguments. 116 | */ 117 | public function withStatus(int $code, string $reasonPhrase = ''): self 118 | { 119 | if ($code < 100 || $code > 599) { 120 | throw new InvalidArgumentException("Invalid status code '{$code}' provided."); 121 | } 122 | $new = clone $this; 123 | $new->code = $code; 124 | $new->reason = $reasonPhrase; 125 | return $new; 126 | } 127 | 128 | /** 129 | * Gets the response reason phrase associated with the status code. 130 | * @return string Reason phrase; must return an empty string if none present. 131 | */ 132 | public function getReasonPhrase(): string 133 | { 134 | $d = self::$codes[$this->code]; 135 | return $this->reason ?: $d; 136 | } 137 | 138 | public function __toString(): string 139 | { 140 | return $this->stringable('%s', $this->getStatusCode()); 141 | } 142 | 143 | /** @return array */ 144 | public function getAsArray(): array 145 | { 146 | return array_merge([ 147 | "HTTP/{$this->getProtocolVersion()} {$this->getStatusCode()} {$this->getReasonPhrase()}", 148 | ], parent::getAsArray()); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/Http/ServerRequest.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | public function getServerParams(): array 27 | { 28 | throw new BadMethodCallException("Not implemented."); 29 | } 30 | 31 | /** 32 | * Retrieves cookies sent by the client to the server. 33 | * @return array 34 | */ 35 | public function getCookieParams(): array 36 | { 37 | throw new BadMethodCallException("Not implemented."); 38 | } 39 | 40 | /** 41 | * Return an instance with the specified cookies. 42 | * @param array $cookies Array of key/value pairs representing cookies. 43 | * @return static 44 | */ 45 | public function withCookieParams(array $cookies): self 46 | { 47 | throw new BadMethodCallException("Not implemented."); 48 | } 49 | 50 | /** 51 | * Retrieves the deserialized query string arguments, if any. 52 | * @return array 53 | */ 54 | public function getQueryParams(): array 55 | { 56 | parse_str($this->getUri()->getQuery(), $result); 57 | return $result; 58 | } 59 | 60 | /** 61 | * Return an instance with the specified query string arguments. 62 | * @param array $query Array of query string arguments 63 | * @return static 64 | */ 65 | public function withQueryParams(array $query): self 66 | { 67 | throw new BadMethodCallException("Not implemented."); 68 | } 69 | 70 | /** 71 | * Retrieve normalized file upload data. 72 | * @return array An array tree of UploadedFileInterface instances. 73 | */ 74 | public function getUploadedFiles(): array 75 | { 76 | throw new BadMethodCallException("Not implemented."); 77 | } 78 | 79 | /** 80 | * Create a new instance with the specified uploaded files. 81 | * @param array $uploadedFiles An array tree of UploadedFileInterface instances. 82 | * @return static 83 | */ 84 | public function withUploadedFiles(array $uploadedFiles): self 85 | { 86 | throw new BadMethodCallException("Not implemented."); 87 | } 88 | 89 | /** 90 | * Retrieve any parameters provided in the request body. 91 | * @return null|array|object The deserialized body parameters, if any. 92 | */ 93 | public function getParsedBody() 94 | { 95 | throw new BadMethodCallException("Not implemented."); 96 | } 97 | 98 | /** 99 | * Return an instance with the specified body parameters. 100 | * @param null|array|object $data The deserialized body data. 101 | * @return static 102 | */ 103 | public function withParsedBody($data): self 104 | { 105 | throw new BadMethodCallException("Not implemented."); 106 | } 107 | 108 | /** 109 | * Retrieve attributes derived from the request. 110 | * @return mixed[] Attributes derived from the request. 111 | */ 112 | public function getAttributes(): array 113 | { 114 | throw new BadMethodCallException("Not implemented."); 115 | } 116 | 117 | /** 118 | * Retrieve a single derived request attribute. 119 | * @param string $name The attribute name. 120 | * @param mixed $default Default value to return if the attribute does not exist. 121 | * @return mixed 122 | */ 123 | public function getAttribute(string $name, $default = null) 124 | { 125 | throw new BadMethodCallException("Not implemented."); 126 | } 127 | 128 | /** 129 | * Return an instance with the specified derived request attribute. 130 | * @param string $name The attribute name. 131 | * @param mixed $value The value of the attribute. 132 | * @return static 133 | */ 134 | public function withAttribute(string $name, $value): self 135 | { 136 | throw new BadMethodCallException("Not implemented."); 137 | } 138 | 139 | /** 140 | * Return an instance that removes the specified derived request attribute. 141 | * @param string $name The attribute name. 142 | * @return static 143 | */ 144 | public function withoutAttribute(string $name): self 145 | { 146 | throw new BadMethodCallException("Not implemented."); 147 | } 148 | 149 | public function __toString(): string 150 | { 151 | return $this->stringable('%s %s', $this->getMethod(), $this->getRequestTarget()); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Message/Binary.php: -------------------------------------------------------------------------------- 1 | compress; 21 | } 22 | 23 | public function setCompress(bool $compress): void 24 | { 25 | $this->compress = $compress; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Message/Close.php: -------------------------------------------------------------------------------- 1 | status = $status; 22 | parent::__construct($content); 23 | } 24 | 25 | public function getCloseStatus(): int|null 26 | { 27 | return $this->status; 28 | } 29 | 30 | public function setCloseStatus(int|null $status): void 31 | { 32 | $this->status = $status; 33 | } 34 | 35 | public function getPayload(): string 36 | { 37 | $statusBinstr = sprintf('%016b', $this->status); 38 | $statusStr = ''; 39 | foreach (str_split($statusBinstr, 8) as $binstr) { 40 | $statusStr .= chr((int)bindec($binstr)); 41 | } 42 | return $statusStr . $this->content; 43 | } 44 | 45 | public function setPayload(string $payload = ''): void 46 | { 47 | $this->status = 0; 48 | $this->content = ''; 49 | if (strlen($payload) > 0) { 50 | $this->status = current(unpack('n', $payload) ?: []); 51 | } 52 | if (strlen($payload) > 2) { 53 | $this->content = substr($payload, 2); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Message/Message.php: -------------------------------------------------------------------------------- 1 | content = $content; 33 | $this->timestamp = new DateTimeImmutable(); 34 | } 35 | 36 | public function getOpcode(): string 37 | { 38 | return $this->opcode; 39 | } 40 | 41 | public function getLength(): int 42 | { 43 | return strlen($this->content); 44 | } 45 | 46 | public function getTimestamp(): DateTimeInterface 47 | { 48 | return $this->timestamp; 49 | } 50 | 51 | public function getContent(): string 52 | { 53 | return $this->content; 54 | } 55 | 56 | public function setContent(string $content = ''): void 57 | { 58 | $this->content = $content; 59 | } 60 | 61 | public function hasContent(): bool 62 | { 63 | return $this->content != ''; 64 | } 65 | 66 | public function getPayload(): string 67 | { 68 | return $this->content; 69 | } 70 | 71 | public function setPayload(string $payload = ''): void 72 | { 73 | $this->content = $payload; 74 | } 75 | 76 | public function isCompressed(): bool 77 | { 78 | return false; 79 | } 80 | 81 | public function setCompress(bool $compress): void 82 | { 83 | if ($compress) { 84 | throw new ConnectionFailureException('Must not compress control message.'); 85 | } 86 | } 87 | 88 | /** 89 | * Split messages into frames 90 | * @param int<1, max> $frameSize 91 | * @return array 92 | */ 93 | public function getFrames(int $frameSize = 4096): array 94 | { 95 | $frames = []; 96 | $split = str_split($this->getPayload(), $frameSize); 97 | if (empty($split)) { 98 | $split = ['']; 99 | } 100 | foreach ($split as $i => $payload) { 101 | $frames[] = new Frame( 102 | $i === 0 ? $this->opcode : 'continuation', 103 | $payload, 104 | $i === array_key_last($split) 105 | ); 106 | } 107 | if ($this->isCompressed()) { 108 | $frames[0]->setRsv1(true); 109 | } 110 | return $frames; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Message/MessageHandler.php: -------------------------------------------------------------------------------- 1 | $frameBuffer */ 38 | private array $frameBuffer = []; 39 | 40 | public function __construct(FrameHandler $frameHandler) 41 | { 42 | $this->frameHandler = $frameHandler; 43 | $this->initLogger(); 44 | } 45 | 46 | public function setLogger(LoggerInterface $logger): void 47 | { 48 | $this->logger = $logger; 49 | $this->frameHandler->setLogger($logger); 50 | } 51 | 52 | /** 53 | * Push message 54 | * @template T of Message 55 | * @param T $message 56 | * @param int<1, max> $size 57 | * @return T 58 | */ 59 | public function push(Message $message, int $size = self::DEFAULT_SIZE): Message 60 | { 61 | $frames = $message->getFrames($size); 62 | foreach ($frames as $frame) { 63 | $this->frameHandler->push($frame); 64 | } 65 | $this->logger->info("[message-handler] Pushed {$message}", [ 66 | 'opcode' => $message->getOpcode(), 67 | 'content-length' => $message->getLength(), 68 | 'frames' => count($frames), 69 | ]); 70 | return $message; 71 | } 72 | 73 | // Pull message 74 | public function pull(): Message 75 | { 76 | do { 77 | $frame = $this->frameHandler->pull(); 78 | if ($frame->isFinal()) { 79 | if ($frame->isContinuation()) { 80 | $frames = array_merge($this->frameBuffer, [$frame]); 81 | $this->frameBuffer = []; // Clear buffer 82 | } else { 83 | $frames = [$frame]; 84 | } 85 | return $this->createMessage($frames); 86 | } 87 | // Non-final frame - add to buffer for continuous reading 88 | $this->frameBuffer[] = $frame; 89 | } while (true); 90 | } 91 | 92 | /** 93 | * @param non-empty-array $frames 94 | */ 95 | private function createMessage(array $frames): Message 96 | { 97 | $opcode = $frames[0]->getOpcode() ?? null; 98 | $message = match ($opcode) { 99 | 'text' => new Text(), 100 | 'binary' => new Binary(), 101 | 'ping' => new Ping(), 102 | 'pong' => new Pong(), 103 | 'close' => new Close(), 104 | default => throw new BadOpcodeException("Invalid opcode '{$opcode}' provided"), 105 | }; 106 | $message->setPayload(array_reduce($frames, function (string $carry, Frame $item) { 107 | return $carry . $item->getPayload(); 108 | }, '')); 109 | $message->setCompress($frames[0]->getRsv1() ?? false); 110 | $this->logger->info("[message-handler] Pulled {$message}", [ 111 | 'opcode' => $message->getOpcode(), 112 | 'content-length' => $message->getLength(), 113 | 'frames' => count($frames), 114 | ]); 115 | return $message; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Message/Ping.php: -------------------------------------------------------------------------------- 1 | compress; 21 | } 22 | 23 | public function setCompress(bool $compress): void 24 | { 25 | $this->compress = $compress; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Middleware/Callback.php: -------------------------------------------------------------------------------- 1 | incoming = $incoming; 51 | $this->outgoing = $outgoing; 52 | $this->httpIncoming = $httpIncoming; 53 | $this->httpOutgoing = $httpOutgoing; 54 | $this->tick = $tick; 55 | $this->initLogger(); 56 | } 57 | 58 | public function processIncoming(ProcessStack $stack, Connection $connection): Message 59 | { 60 | if (is_callable($this->incoming)) { 61 | return call_user_func($this->incoming, $stack, $connection); 62 | } 63 | return $stack->handleIncoming(); 64 | } 65 | 66 | public function processOutgoing(ProcessStack $stack, Connection $connection, Message $message): Message 67 | { 68 | if (is_callable($this->outgoing)) { 69 | return call_user_func($this->outgoing, $stack, $connection, $message); 70 | } 71 | return $stack->handleOutgoing($message); 72 | } 73 | 74 | public function processHttpIncoming(ProcessHttpStack $stack, Connection $connection): MessageInterface 75 | { 76 | if (is_callable($this->httpIncoming)) { 77 | return call_user_func($this->httpIncoming, $stack, $connection); 78 | } 79 | return $stack->handleHttpIncoming(); 80 | } 81 | 82 | public function processHttpOutgoing( 83 | ProcessHttpStack $stack, 84 | Connection $connection, 85 | MessageInterface $message 86 | ): MessageInterface { 87 | if (is_callable($this->httpOutgoing)) { 88 | return call_user_func($this->httpOutgoing, $stack, $connection, $message); 89 | } 90 | return $stack->handleHttpOutgoing($message); 91 | } 92 | 93 | public function processTick(ProcessTickStack $stack, Connection $connection): void 94 | { 95 | if (is_callable($this->tick)) { 96 | call_user_func($this->tick, $stack, $connection); 97 | } 98 | $stack->handleTick(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Middleware/CloseHandler.php: -------------------------------------------------------------------------------- 1 | initLogger(); 34 | } 35 | 36 | public function processIncoming(ProcessStack $stack, Connection $connection): Message 37 | { 38 | $message = $stack->handleIncoming(); // Proceed before logic 39 | if (!$message instanceof Close) { 40 | return $message; 41 | } 42 | if ($connection->isWritable()) { 43 | // Remote sent Close; acknowledge and close for further reading 44 | $this->logger->debug("[close-handler] Received 'close', status: {$message->getCloseStatus()}"); 45 | $ack = "Close acknowledged: {$message->getCloseStatus()}"; 46 | $connection->closeRead(); 47 | $connection->send(new Close($message->getCloseStatus(), $ack)); 48 | } else { 49 | // Remote sent Close/Ack: disconnect 50 | $this->logger->debug("[close-handler] Received 'close' acknowledge, disconnecting"); 51 | $connection->disconnect(); 52 | } 53 | return $message; 54 | } 55 | 56 | public function processOutgoing(ProcessStack $stack, Connection $connection, Message $message): Message 57 | { 58 | $message = $stack->handleOutgoing($message); // Proceed before logic 59 | if (!$message instanceof Close) { 60 | return $message; 61 | } 62 | if ($connection->isReadable()) { 63 | // Local sent Close: close for further writing, expect remote acknowledge 64 | $this->logger->debug("[close-handler] Sent 'close', status: {$message->getCloseStatus()}"); 65 | $connection->closeWrite(); 66 | } else { 67 | // Local sent Close/Ack: disconnect 68 | $this->logger->debug("[close-handler] Sent 'close' acknowledge, disconnecting"); 69 | $connection->disconnect(); 70 | } 71 | return $message; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Middleware/CompressionExtension.php: -------------------------------------------------------------------------------- 1 | $compressors */ 49 | private array $compressors = []; 50 | 51 | public function __construct(CompressorInterface ...$compressors) 52 | { 53 | $this->compressors = $compressors; 54 | $this->initLogger(); 55 | } 56 | 57 | public function processHttpOutgoing( 58 | ProcessHttpStack $stack, 59 | Connection $connection, 60 | MessageInterface $message 61 | ): MessageInterface { 62 | if ($message instanceof RequestInterface) { 63 | // Outgoing requests on Client 64 | $connection->setMeta('compressionExtension.compressor', null); 65 | $connection->setMeta('compressionExtension.configuration', null); 66 | $headerValues = []; 67 | foreach ($this->compressors as $compressor) { 68 | $headerValues[] = $compressor->getRequestHeaderValue(); 69 | } 70 | $message = $message->withAddedHeader('Sec-WebSocket-Extensions', implode(', ', $headerValues)); 71 | } elseif ($message instanceof ResponseInterface) { 72 | // Outgoing Response on Server 73 | if ($compressor = $connection->getMeta('compressionExtension.compressor')) { 74 | $configuration = $connection->getMeta('compressionExtension.configuration'); 75 | $message = $message->withHeader( 76 | 'Sec-WebSocket-Extensions', 77 | $compressor->getResponseHeaderValue($configuration) 78 | ); 79 | } 80 | } 81 | return $stack->handleHttpOutgoing($message); 82 | } 83 | 84 | public function processHttpIncoming(ProcessHttpStack $stack, Connection $connection): MessageInterface 85 | { 86 | $message = $stack->handleHttpIncoming(); 87 | if ($message instanceof ServerRequestInterface) { 88 | // Incoming requests on Server 89 | $connection->setMeta('compressionExtension.compressor', null); 90 | $connection->setMeta('compressionExtension.configuration', null); 91 | if ($preferred = $this->getPreferred($message)) { 92 | $connection->setMeta('compressionExtension.compressor', $preferred->compressor); 93 | $connection->setMeta('compressionExtension.configuration', $preferred->configuration); 94 | $this->logger->debug( 95 | "[permessage-compression] Using {$preferred->compressor}", 96 | (array)$preferred->configuration 97 | ); 98 | } 99 | } elseif ($message instanceof ResponseInterface) { 100 | // Incoming Response on Client 101 | if ($preferred = $this->getPreferred($message)) { 102 | $connection->setMeta('compressionExtension.compressor', $preferred->compressor); 103 | $connection->setMeta('compressionExtension.configuration', $preferred->configuration); 104 | $this->logger->debug( 105 | "[permessage-compression] Using {$preferred->compressor}", 106 | (array)$preferred->configuration 107 | ); 108 | } 109 | // @todo: If not found? 110 | } 111 | return $message; 112 | } 113 | 114 | public function processIncoming(ProcessStack $stack, Connection $connection): Message 115 | { 116 | $message = $stack->handleIncoming(); 117 | if ( 118 | ($message instanceof Text || $message instanceof Binary) 119 | && $message->isCompressed() 120 | && $compressor = $connection->getMeta('compressionExtension.compressor') 121 | ) { 122 | $message = $compressor->decompress($message, $connection->getMeta('compressionExtension.configuration')); 123 | } 124 | return $message; 125 | } 126 | 127 | /** 128 | * @template T of Message 129 | * @param T $message 130 | * @return T|Text|Binary 131 | */ 132 | public function processOutgoing(ProcessStack $stack, Connection $connection, Message $message): Message 133 | { 134 | if ( 135 | ($message instanceof Text || $message instanceof Binary) 136 | && !$message->isCompressed() 137 | && $compressor = $connection->getMeta('compressionExtension.compressor') 138 | ) { 139 | /** @var Text|Binary $message */ 140 | $message = $compressor->compress($message, $connection->getMeta('compressionExtension.configuration')); 141 | } 142 | return $stack->handleOutgoing($message); 143 | } 144 | 145 | /** 146 | * @return object{compressor: CompressorInterface, configuration: object}|null 147 | */ 148 | protected function getPreferred(MessageInterface $request): object|null 149 | { 150 | $isServer = $request instanceof ServerRequestInterface; 151 | foreach ($request->getHeader('Sec-WebSocket-Extensions') as $header) { 152 | foreach (explode(',', $header) as $element) { 153 | foreach ($this->compressors as $compressor) { 154 | $configuration = $compressor->getConfiguration(trim($element), $isServer); 155 | if ($compressor->isEligable($configuration)) { 156 | return (object)['compressor' => $compressor, 'configuration' => $configuration]; 157 | } 158 | } 159 | } 160 | } 161 | return null; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Middleware/CompressionExtension/CompressorInterface.php: -------------------------------------------------------------------------------- 1 | , 38 | * clientMaxWindowBits: int, 39 | * } 40 | */ 41 | class DeflateCompressor implements CompressorInterface, Stringable 42 | { 43 | use StringableTrait; 44 | 45 | private const MIN_WINDOW_SIZE = 9; 46 | private const MAX_WINDOW_SIZE = 15; 47 | 48 | private bool $serverNoContextTakeover; 49 | private bool $clientNoContextTakeover; 50 | /** @var int $serverMaxWindowBits */ 51 | private int $serverMaxWindowBits; 52 | /** @var int $clientMaxWindowBits */ 53 | private int $clientMaxWindowBits; 54 | 55 | public function __construct( 56 | bool $serverNoContextTakeover = false, 57 | bool $clientNoContextTakeover = false, 58 | int $serverMaxWindowBits = self::MAX_WINDOW_SIZE, 59 | int $clientMaxWindowBits = self::MAX_WINDOW_SIZE, 60 | string $extension = 'zlib', 61 | ) { 62 | if (!extension_loaded($extension)) { 63 | throw new RuntimeException("DeflateCompressor require {$extension} extension."); 64 | } 65 | if ($serverMaxWindowBits < self::MIN_WINDOW_SIZE || $serverMaxWindowBits > self::MAX_WINDOW_SIZE) { 66 | throw new RangeException("DeflateCompressor serverMaxWindowBits must be in range 9-15."); 67 | } 68 | if ($clientMaxWindowBits < self::MIN_WINDOW_SIZE || $clientMaxWindowBits > self::MAX_WINDOW_SIZE) { 69 | throw new RangeException("DeflateCompressor clientMaxWindowBits must be in range 9-15."); 70 | } 71 | $this->serverNoContextTakeover = $serverNoContextTakeover; 72 | $this->clientNoContextTakeover = $clientNoContextTakeover; 73 | $this->serverMaxWindowBits = $serverMaxWindowBits; 74 | $this->clientMaxWindowBits = $clientMaxWindowBits; 75 | } 76 | 77 | public function getRequestHeaderValue(): string 78 | { 79 | $header = "permessage-deflate"; 80 | if ($this->serverNoContextTakeover) { 81 | $header .= "; server_no_context_takeover"; 82 | } 83 | if ($this->clientNoContextTakeover) { 84 | $header .= "; client_no_context_takeover"; 85 | } 86 | if ($this->serverMaxWindowBits != self::MAX_WINDOW_SIZE) { 87 | $header .= "; server_max_window_bits={$this->serverMaxWindowBits}"; 88 | } 89 | if ($this->clientMaxWindowBits != self::MAX_WINDOW_SIZE) { 90 | $header .= "; client_max_window_bits={$this->clientMaxWindowBits}"; 91 | } 92 | return $header; 93 | } 94 | 95 | /** 96 | * @param Config $configuration 97 | */ 98 | public function getResponseHeaderValue(object $configuration): string 99 | { 100 | // @todo: throw HandshakeException or bad config 101 | $header = "permessage-deflate"; 102 | if ($configuration->serverNoContextTakeover) { 103 | $header .= "; server_no_context_takeover"; 104 | } 105 | if ($configuration->clientNoContextTakeover) { 106 | $header .= "; client_no_context_takeover"; 107 | } 108 | $serverMaxWindowBits = min($configuration->serverMaxWindowBits, $this->serverMaxWindowBits); 109 | if ($serverMaxWindowBits != self::MAX_WINDOW_SIZE) { 110 | $header .= "; server_max_window_bits={$serverMaxWindowBits}"; 111 | } 112 | $clientMaxWindowBits = min($configuration->clientMaxWindowBits, $this->clientMaxWindowBits); 113 | if ($clientMaxWindowBits != self::MAX_WINDOW_SIZE) { 114 | $header .= "; client_max_window_bits={$clientMaxWindowBits}"; 115 | } 116 | return $header; 117 | } 118 | 119 | /** 120 | * @param Config $configuration 121 | */ 122 | public function isEligable(object $configuration): bool 123 | { 124 | return 125 | $configuration->compressor == 'permessage-deflate' 126 | && $configuration->serverMaxWindowBits <= $this->serverMaxWindowBits 127 | && $configuration->clientMaxWindowBits <= $this->clientMaxWindowBits 128 | ; 129 | } 130 | 131 | /** 132 | * @return Config 133 | */ 134 | public function getConfiguration(string $element, bool $isServer): object 135 | { 136 | $configuration = (object)[ 137 | 'compressor' => null, 138 | 'isServer' => $isServer, 139 | 'serverNoContextTakeover' => $this->serverNoContextTakeover, 140 | 'clientNoContextTakeover' => $this->clientNoContextTakeover, 141 | 'serverMaxWindowBits' => $this->serverMaxWindowBits, 142 | 'clientMaxWindowBits' => $this->clientMaxWindowBits, 143 | 'deflator' => null, 144 | 'inflator' => null, 145 | ]; 146 | foreach (explode(';', $element) as $parameter) { 147 | $parts = explode('=', $parameter); 148 | $key = trim($parts[0]); 149 | // @todo: Error handling when parsing 150 | switch ($key) { 151 | case 'permessage-deflate': 152 | $configuration->compressor = $key; 153 | break; 154 | case 'server_no_context_takeover': 155 | $configuration->serverNoContextTakeover = true; 156 | break; 157 | case 'client_no_context_takeover': 158 | $configuration->clientNoContextTakeover = true; 159 | break; 160 | case 'server_max_window_bits': 161 | $bits = intval($parts[1] ?? self::MAX_WINDOW_SIZE); 162 | $configuration->serverMaxWindowBits = min($bits, $this->serverMaxWindowBits); 163 | break; 164 | case 'client_max_window_bits': 165 | $bits = intval($parts[1] ?? self::MAX_WINDOW_SIZE); 166 | $configuration->clientMaxWindowBits = min($bits, $this->clientMaxWindowBits); 167 | break; 168 | } 169 | } 170 | return $configuration; 171 | } 172 | 173 | /** 174 | * @template T of Binary|Text 175 | * @param T $message 176 | * @param Config $configuration 177 | * @return T 178 | */ 179 | public function compress(Binary|Text $message, object $configuration): Binary|Text 180 | { 181 | $windowBits = $configuration->isServer 182 | ? $configuration->serverMaxWindowBits 183 | : $configuration->clientMaxWindowBits; 184 | $noContextTakeover = $configuration->isServer 185 | ? $configuration->serverNoContextTakeover 186 | : $configuration->clientNoContextTakeover; 187 | 188 | if (is_null($configuration->deflator) || $noContextTakeover) { 189 | $configuration->deflator = deflate_init(ZLIB_ENCODING_RAW, [ 190 | 'level' => -1, 191 | 'window' => $windowBits, 192 | 'strategy' => ZLIB_DEFAULT_STRATEGY 193 | ]) ?: null; 194 | } 195 | /** @var DeflateContext $deflator */ 196 | $deflator = $configuration->deflator; 197 | /** @var string $deflated */ 198 | $deflated = deflate_add($deflator, $message->getPayload(), ZLIB_SYNC_FLUSH); 199 | $deflated = substr($deflated, 0, -4); // Remove 4 last chars 200 | $message->setCompress(true); 201 | $message->setPayload($deflated); 202 | return $message; 203 | } 204 | 205 | /** 206 | * @param Config $configuration 207 | */ 208 | public function decompress(Binary|Text $message, object $configuration): Binary|Text 209 | { 210 | $windowBits = $configuration->isServer 211 | ? $configuration->clientMaxWindowBits 212 | : $configuration->serverMaxWindowBits; 213 | $noContextTakeover = $configuration->isServer 214 | ? $configuration->clientNoContextTakeover 215 | : $configuration->serverNoContextTakeover; 216 | 217 | if (is_null($configuration->inflator) || $noContextTakeover) { 218 | $configuration->inflator = inflate_init(ZLIB_ENCODING_RAW, [ 219 | 'level' => -1, 220 | 'window' => $windowBits, 221 | 'strategy' => ZLIB_DEFAULT_STRATEGY 222 | ]) ?: null; 223 | } 224 | /** @var InflateContext $inflator */ 225 | $inflator = $configuration->inflator; 226 | /** @var string $inflated */ 227 | $inflated = inflate_add($inflator, $message->getPayload() . "\x00\x00\xff\xff"); 228 | $message->setCompress(false); 229 | $message->setPayload($inflated); 230 | return $message; 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/Middleware/FollowRedirect.php: -------------------------------------------------------------------------------- 1 | limit = $limit; 44 | $this->initLogger(); 45 | } 46 | 47 | public function processHttpIncoming(ProcessHttpStack $stack, Connection $connection): MessageInterface 48 | { 49 | $message = $stack->handleHttpIncoming(); 50 | if ( 51 | $message instanceof ResponseInterface 52 | && $message->getStatusCode() >= 300 53 | && $message->getStatusCode() < 400 54 | && $locationHeader = $message->getHeaderLine('Location') 55 | ) { 56 | $note = "{$this->attempts} of {$this->limit} redirect attempts"; 57 | if ($this->attempts > $this->limit) { 58 | $this->logger->debug("[follow-redirect] Too many redirect attempts, giving up"); 59 | throw new HandshakeException("Too many redirect attempts, giving up", $message); 60 | } 61 | $this->attempts++; 62 | $this->logger->debug("[follow-redirect] {$message->getStatusCode()} {$locationHeader} ($note)"); 63 | throw new ReconnectException(new Uri($locationHeader)); 64 | } 65 | return $message; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Middleware/MiddlewareHandler.php: -------------------------------------------------------------------------------- 1 | */ 39 | private array $middlewares = []; 40 | /** @var array */ 41 | private array $incoming = []; 42 | /** @var array */ 43 | private array $outgoing = []; 44 | /** @var array */ 45 | private array $httpIncoming = []; 46 | /** @var array */ 47 | private array $httpOutgoing = []; 48 | /** @var array */ 49 | private array $tick = []; 50 | 51 | // Handlers 52 | private HttpHandler $httpHandler; 53 | private MessageHandler $messageHandler; 54 | 55 | /** 56 | * Create MiddlewareHandler. 57 | * @param MessageHandler $messageHandler 58 | * @param HttpHandler $httpHandler 59 | */ 60 | public function __construct(MessageHandler $messageHandler, HttpHandler $httpHandler) 61 | { 62 | $this->messageHandler = $messageHandler; 63 | $this->httpHandler = $httpHandler; 64 | $this->initLogger(); 65 | } 66 | 67 | /** 68 | * Set logger on MiddlewareHandler and all LoggerAware middlewares. 69 | * @param LoggerInterface $logger 70 | */ 71 | public function setLogger(LoggerInterface $logger): void 72 | { 73 | $this->logger = $logger; 74 | foreach ($this->middlewares as $middleware) { 75 | $this->attachLogger($middleware); 76 | } 77 | } 78 | 79 | /** 80 | * Add a middleware. 81 | * @param MiddlewareInterface $middleware 82 | * @return $this 83 | */ 84 | public function add(MiddlewareInterface $middleware): self 85 | { 86 | if ($middleware instanceof ProcessIncomingInterface) { 87 | $this->logger->info("[middleware-handler] Added incoming: {$middleware}"); 88 | $this->incoming[] = $middleware; 89 | } 90 | if ($middleware instanceof ProcessOutgoingInterface) { 91 | $this->logger->info("[middleware-handler] Added outgoing: {$middleware}"); 92 | $this->outgoing[] = $middleware; 93 | } 94 | if ($middleware instanceof ProcessHttpIncomingInterface) { 95 | $this->logger->info("[middleware-handler] Added http incoming: {$middleware}"); 96 | $this->httpIncoming[] = $middleware; 97 | } 98 | if ($middleware instanceof ProcessHttpOutgoingInterface) { 99 | $this->logger->info("[middleware-handler] Added http outgoing: {$middleware}"); 100 | $this->httpOutgoing[] = $middleware; 101 | } 102 | if ($middleware instanceof ProcessTickInterface) { 103 | $this->logger->info("[middleware-handler] Added tick: {$middleware}"); 104 | $this->tick[] = $middleware; 105 | } 106 | $this->attachLogger($middleware); 107 | $this->middlewares[] = $middleware; 108 | return $this; 109 | } 110 | 111 | /** 112 | * Process middlewares for incoming messages. 113 | * @param Connection $connection 114 | * @return Message 115 | */ 116 | public function processIncoming(Connection $connection): Message 117 | { 118 | $this->logger->info("[middleware-handler] Processing incoming"); 119 | $stack = new ProcessStack($connection, $this->messageHandler, $this->incoming); 120 | return $stack->handleIncoming(); 121 | } 122 | 123 | /** 124 | * Process middlewares for outgoing messages. 125 | * @template T of Message 126 | * @param Connection $connection 127 | * @param T $message 128 | * @return T 129 | */ 130 | public function processOutgoing(Connection $connection, Message $message): Message 131 | { 132 | $this->logger->info("[middleware-handler] Processing outgoing"); 133 | $stack = new ProcessStack($connection, $this->messageHandler, $this->outgoing); 134 | return $stack->handleOutgoing($message); 135 | } 136 | 137 | /** 138 | * Process middlewares for http requests. 139 | * @param Connection $connection 140 | * @return MessageInterface 141 | */ 142 | public function processHttpIncoming(Connection $connection): MessageInterface 143 | { 144 | $this->logger->info("[middleware-handler] Processing http incoming"); 145 | $stack = new ProcessHttpStack($connection, $this->httpHandler, $this->httpIncoming); 146 | return $stack->handleHttpIncoming(); 147 | } 148 | 149 | /** 150 | * Process middlewares for http requests. 151 | * @param Connection $connection 152 | * @param MessageInterface $message 153 | * @return MessageInterface 154 | */ 155 | public function processHttpOutgoing(Connection $connection, MessageInterface $message): MessageInterface 156 | { 157 | $this->logger->info("[middleware-handler] Processing http outgoing"); 158 | $stack = new ProcessHttpStack($connection, $this->httpHandler, $this->httpOutgoing); 159 | return $stack->handleHttpOutgoing($message); 160 | } 161 | 162 | /** 163 | * Process middlewares for tick. 164 | * @param Connection $connection 165 | */ 166 | public function processTick(Connection $connection): void 167 | { 168 | $this->logger->info("[middleware-handler] Processing tick"); 169 | $stack = new ProcessTickStack($connection, $this->tick); 170 | $stack->handleTick(); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/Middleware/MiddlewareInterface.php: -------------------------------------------------------------------------------- 1 | interval = $interval; 36 | $this->initLogger(); 37 | } 38 | 39 | public function processOutgoing(ProcessStack $stack, Connection $connection, Message $message): Message 40 | { 41 | $this->setNext($connection); // Update timestamp for next ping 42 | return $stack->handleOutgoing($message); 43 | } 44 | 45 | public function processTick(ProcessTickStack $stack, Connection $connection): void 46 | { 47 | // Push if time exceeds timestamp for next ping 48 | if ($connection->isWritable() && microtime(true) >= $this->getNext($connection)) { 49 | $this->logger->debug("[ping-interval] Auto-pushing ping"); 50 | $connection->send(new Ping()); 51 | $this->setNext($connection); // Update timestamp for next ping 52 | } 53 | $stack->handleTick(); 54 | } 55 | 56 | private function getNext(Connection $connection): float 57 | { 58 | return $connection->getMeta('pingInterval.next') ?? $this->setNext($connection); 59 | } 60 | 61 | private function setNext(Connection $connection): float 62 | { 63 | $next = microtime(true) + ($this->interval ?? $connection->getTimeout()); 64 | $connection->setMeta('pingInterval.next', $next); 65 | return $next; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Middleware/PingResponder.php: -------------------------------------------------------------------------------- 1 | initLogger(); 35 | } 36 | 37 | public function processIncoming(ProcessStack $stack, Connection $connection): Message 38 | { 39 | $message = $stack->handleIncoming(); 40 | if ($message instanceof Ping && $connection->isWritable()) { 41 | $connection->send(new Pong($message->getContent())); 42 | } 43 | return $message; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Middleware/ProcessHttpIncomingInterface.php: -------------------------------------------------------------------------------- 1 | $processors */ 27 | private array $processors; 28 | 29 | /** 30 | * Create ProcessStack. 31 | * @param Connection $connection 32 | * @param HttpHandler $httpHandler 33 | * @param array $processors 34 | */ 35 | public function __construct(Connection $connection, HttpHandler $httpHandler, array $processors) 36 | { 37 | $this->connection = $connection; 38 | $this->httpHandler = $httpHandler; 39 | $this->processors = $processors; 40 | } 41 | 42 | /** 43 | * Process middleware for incoming http message. 44 | * @return MessageInterface 45 | */ 46 | public function handleHttpIncoming(): MessageInterface 47 | { 48 | /** @var ProcessHttpIncomingInterface|null $processor */ 49 | $processor = array_shift($this->processors); 50 | if ($processor) { 51 | return $processor->processHttpIncoming($this, $this->connection); 52 | } 53 | return $this->httpHandler->pull(); 54 | } 55 | 56 | /** 57 | * Process middleware for outgoing http message. 58 | * @param MessageInterface $message 59 | * @return MessageInterface 60 | */ 61 | public function handleHttpOutgoing(MessageInterface $message): MessageInterface 62 | { 63 | /** @var ProcessHttpOutgoingInterface|null $processor */ 64 | $processor = array_shift($this->processors); 65 | if ($processor) { 66 | return $processor->processHttpOutgoing($this, $this->connection, $message); 67 | } 68 | return $this->httpHandler->push($message); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Middleware/ProcessIncomingInterface.php: -------------------------------------------------------------------------------- 1 | $processors */ 29 | private array $processors; 30 | 31 | /** 32 | * Create ProcessStack. 33 | * @param Connection $connection 34 | * @param MessageHandler $messageHandler 35 | * @param array $processors 36 | */ 37 | public function __construct(Connection $connection, MessageHandler $messageHandler, array $processors) 38 | { 39 | $this->connection = $connection; 40 | $this->messageHandler = $messageHandler; 41 | $this->processors = $processors; 42 | } 43 | 44 | /** 45 | * Process middleware for incoming message. 46 | * @return Message 47 | */ 48 | public function handleIncoming(): Message 49 | { 50 | /** @var ProcessIncomingInterface|null $processor */ 51 | $processor = array_shift($this->processors); 52 | if ($processor) { 53 | return $processor->processIncoming($this, $this->connection); 54 | } 55 | return $this->messageHandler->pull(); 56 | } 57 | 58 | /** 59 | * Process middleware for outgoing message. 60 | * @template T of Message 61 | * @param T $message 62 | * @return T 63 | */ 64 | public function handleOutgoing(Message $message): Message 65 | { 66 | /** @var ProcessOutgoingInterface|null $processor */ 67 | $processor = array_shift($this->processors); 68 | if ($processor) { 69 | return $processor->processOutgoing($this, $this->connection, $message); 70 | } 71 | return $this->messageHandler->push($message, $this->connection->getFrameSize()); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Middleware/ProcessTickInterface.php: -------------------------------------------------------------------------------- 1 | $processors */ 24 | private array $processors; 25 | 26 | /** 27 | * Create ProcessStack. 28 | * @param Connection $connection 29 | * @param array $processors 30 | */ 31 | public function __construct(Connection $connection, array $processors) 32 | { 33 | $this->connection = $connection; 34 | $this->processors = $processors; 35 | } 36 | 37 | /** 38 | * Process middleware for tick. 39 | */ 40 | public function handleTick(): void 41 | { 42 | $processor = array_shift($this->processors); 43 | if ($processor) { 44 | $processor->processTick($this, $this->connection); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Middleware/SubprotocolNegotiation.php: -------------------------------------------------------------------------------- 1 | $subprotocols */ 41 | private array $subprotocols; 42 | private bool $require; 43 | 44 | /** @param array $subprotocols */ 45 | public function __construct(array $subprotocols, bool $require = false) 46 | { 47 | $this->subprotocols = $subprotocols; 48 | $this->require = $require; 49 | $this->initLogger(); 50 | } 51 | 52 | public function processHttpOutgoing( 53 | ProcessHttpStack $stack, 54 | Connection $connection, 55 | MessageInterface $message 56 | ): MessageInterface { 57 | if ($message instanceof RequestInterface) { 58 | // Outgoing requests on Client 59 | foreach ($this->subprotocols as $subprotocol) { 60 | $message = $message->withAddedHeader('Sec-WebSocket-Protocol', $subprotocol); 61 | } 62 | if ($supported = implode(', ', $this->subprotocols)) { 63 | $this->logger->debug("[subprotocol-negotiation] Requested subprotocols: {$supported}"); 64 | } 65 | } elseif ($message instanceof ResponseInterface) { 66 | // Outgoing Response on Server 67 | if ($selected = $connection->getMeta('subprotocolNegotiation.selected')) { 68 | $message = $message->withHeader('Sec-WebSocket-Protocol', $selected); 69 | $this->logger->info("[subprotocol-negotiation] Selected subprotocol: {$selected}"); 70 | } elseif ($this->require) { 71 | // No matching subprotocol, fail handshake 72 | $message = $message->withStatus(426); 73 | } 74 | } 75 | return $stack->handleHttpOutgoing($message); 76 | } 77 | 78 | public function processHttpIncoming(ProcessHttpStack $stack, Connection $connection): MessageInterface 79 | { 80 | $connection->setMeta('subprotocolNegotiation.selected', null); 81 | $message = $stack->handleHttpIncoming(); 82 | 83 | if ($message instanceof ServerRequestInterface) { 84 | // Incoming requests on Server 85 | if ($requested = $message->getHeaderLine('Sec-WebSocket-Protocol')) { 86 | $this->logger->debug("[subprotocol-negotiation] Requested subprotocols: {$requested}"); 87 | } 88 | if ($supported = implode(', ', $this->subprotocols)) { 89 | $this->logger->debug("[subprotocol-negotiation] Supported subprotocols: {$supported}"); 90 | } 91 | foreach ($message->getHeader('Sec-WebSocket-Protocol') as $subprotocol) { 92 | if (in_array($subprotocol, $this->subprotocols)) { 93 | $connection->setMeta('subprotocolNegotiation.selected', $subprotocol); 94 | return $message; 95 | } 96 | } 97 | } elseif ($message instanceof ResponseInterface) { 98 | // Incoming Response on Client 99 | if ($selected = $message->getHeaderLine('Sec-WebSocket-Protocol')) { 100 | $connection->setMeta('subprotocolNegotiation.selected', $selected); 101 | $this->logger->info("[subprotocol-negotiation] Selected subprotocol: {$selected}"); 102 | } elseif ($this->require) { 103 | // No matching subprotocol, close and fail 104 | $connection->close(); 105 | throw new HandshakeException("Could not resolve subprotocol.", $message); 106 | } 107 | } 108 | return $message; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Server.php: -------------------------------------------------------------------------------- 1 | */ 55 | use ListenerTrait; 56 | use LoggerAwareTrait; 57 | use SendMethodsTrait; 58 | use StringableTrait; 59 | 60 | // Settings 61 | private int $port; 62 | private string $scheme; 63 | /** @var int<0, max>|float $timeout */ 64 | private int|float $timeout = 60; 65 | /** @var int<1, max> $frameSize */ 66 | private int $frameSize = 4096; 67 | private Context $context; 68 | 69 | // Internal resources 70 | private StreamFactory $streamFactory; 71 | private SocketServer|null $server = null; 72 | private StreamCollection|null $streams = null; 73 | private bool $running = false; 74 | /** @var array $connections */ 75 | private array $connections = []; 76 | /** @var array $middlewares */ 77 | private array $middlewares = []; 78 | private int|null $maxConnections = null; 79 | 80 | 81 | /* ---------- Magic methods ------------------------------------------------------------------------------------ */ 82 | 83 | /** 84 | * @param int $port Socket port to listen to 85 | * @param bool $ssl If SSL should be used 86 | * @throws InvalidArgumentException If invalid port provided 87 | */ 88 | public function __construct(int $port = 80, bool $ssl = false) 89 | { 90 | if ($port < 0 || $port > 65535) { 91 | throw new InvalidArgumentException("Invalid port '{$port}' provided"); 92 | } 93 | $this->port = $port; 94 | $this->scheme = $ssl ? 'ssl' : 'tcp'; 95 | $this->initLogger(); 96 | $this->context = new Context(); 97 | $this->setStreamFactory(new StreamFactory()); 98 | } 99 | 100 | /** 101 | * Get string representation of instance. 102 | * @return string String representation 103 | */ 104 | public function __toString(): string 105 | { 106 | return $this->stringable('%s', $this->server ? "{$this->scheme}://0.0.0.0:{$this->port}" : 'closed'); 107 | } 108 | 109 | 110 | /* ---------- Configuration ------------------------------------------------------------------------------------ */ 111 | 112 | /** 113 | * Set stream factory to use. 114 | * @param StreamFactory $streamFactory 115 | * @return self 116 | */ 117 | public function setStreamFactory(StreamFactory $streamFactory): self 118 | { 119 | $this->streamFactory = $streamFactory; 120 | return $this; 121 | } 122 | 123 | /** 124 | * Set logger. 125 | * @param LoggerInterface $logger Logger implementation 126 | */ 127 | public function setLogger(LoggerInterface $logger): void 128 | { 129 | $this->logger = $logger; 130 | foreach ($this->connections as $connection) { 131 | $connection->setLogger($this->logger); 132 | } 133 | } 134 | 135 | /** 136 | * Set timeout. 137 | * @param int<0, max>|float $timeout Timeout in seconds 138 | * @return self 139 | * @throws InvalidArgumentException If invalid timeout provided 140 | */ 141 | public function setTimeout(int|float $timeout): self 142 | { 143 | if ($timeout < 0) { 144 | throw new InvalidArgumentException("Invalid timeout '{$timeout}' provided"); 145 | } 146 | $this->timeout = $timeout; 147 | foreach ($this->connections as $connection) { 148 | $connection->setTimeout($timeout); 149 | } 150 | return $this; 151 | } 152 | 153 | /** 154 | * Get timeout. 155 | * @return int<0, max>|float Timeout in seconds 156 | */ 157 | public function getTimeout(): int|float 158 | { 159 | return $this->timeout; 160 | } 161 | 162 | /** 163 | * Set frame size. 164 | * @param int<1, max> $frameSize Frame size in bytes 165 | * @return self 166 | * @throws InvalidArgumentException If invalid frameSize provided 167 | */ 168 | public function setFrameSize(int $frameSize): self 169 | { 170 | if ($frameSize < 3) { 171 | throw new InvalidArgumentException("Invalid frameSize '{$frameSize}' provided"); 172 | } 173 | $this->frameSize = $frameSize; 174 | foreach ($this->connections as $connection) { 175 | $connection->setFrameSize($frameSize); 176 | } 177 | return $this; 178 | } 179 | 180 | /** 181 | * Get frame size. 182 | * @return int Frame size in bytes 183 | */ 184 | public function getFrameSize(): int 185 | { 186 | return $this->frameSize; 187 | } 188 | 189 | /** 190 | * Get socket port number. 191 | * @return int port 192 | */ 193 | public function getPort(): int 194 | { 195 | return $this->port; 196 | } 197 | 198 | /** 199 | * Get connection scheme. 200 | * @return string scheme 201 | */ 202 | public function getScheme(): string 203 | { 204 | return $this->scheme; 205 | } 206 | 207 | /** 208 | * Get connection scheme. 209 | * @return bool SSL mode 210 | */ 211 | public function isSsl(): bool 212 | { 213 | return $this->scheme === 'ssl'; 214 | } 215 | 216 | /** 217 | * Number of currently connected clients. 218 | * @return int Connection count 219 | */ 220 | public function getConnectionCount(): int 221 | { 222 | return count($this->connections); 223 | } 224 | 225 | /** 226 | * Get currently connected clients. 227 | * @return array Connections 228 | */ 229 | public function getConnections(): array 230 | { 231 | return $this->connections; 232 | } 233 | 234 | /** 235 | * Get currently readable clients. 236 | * @return array Connections 237 | */ 238 | public function getReadableConnections(): array 239 | { 240 | return array_filter($this->connections, function (Connection $connection) { 241 | return $connection->isReadable(); 242 | }); 243 | } 244 | 245 | /** 246 | * Get currently writable clients. 247 | * @return array Connections 248 | */ 249 | public function getWritableConnections(): array 250 | { 251 | return array_filter($this->connections, function (Connection $connection) { 252 | return $connection->isWritable(); 253 | }); 254 | } 255 | 256 | /** 257 | * Set stream context. 258 | * @param Context|array $context Context or options as array 259 | * @see https://www.php.net/manual/en/context.php 260 | * @return self 261 | */ 262 | public function setContext(Context|array $context): self 263 | { 264 | if ($context instanceof Context) { 265 | $this->context = $context; 266 | } else { 267 | $this->context->setOptions($context); 268 | } 269 | return $this; 270 | } 271 | 272 | /** 273 | * Get current stream context. 274 | * @return Context 275 | */ 276 | public function getContext(): Context 277 | { 278 | return $this->context; 279 | } 280 | 281 | /** 282 | * Add a middleware. 283 | * @param MiddlewareInterface $middleware 284 | * @return self 285 | */ 286 | public function addMiddleware(MiddlewareInterface $middleware): self 287 | { 288 | $this->middlewares[] = $middleware; 289 | foreach ($this->connections as $connection) { 290 | $connection->addMiddleware($middleware); 291 | } 292 | return $this; 293 | } 294 | 295 | /** 296 | * Set maximum number of connections allowed, null means unlimited. 297 | * @param int|null $maxConnections 298 | * @return self 299 | */ 300 | public function setMaxConnections(int|null $maxConnections): self 301 | { 302 | if ($maxConnections !== null && $maxConnections < 1) { 303 | throw new InvalidArgumentException("Invalid maxConnections '{$maxConnections}' provided"); 304 | } 305 | $this->maxConnections = $maxConnections; 306 | return $this; 307 | } 308 | 309 | 310 | /* ---------- Messaging operations ----------------------------------------------------------------------------- */ 311 | 312 | /** 313 | * Send message (broadcast to all connected clients). 314 | * @template T of Message 315 | * @param T $message 316 | * @return T 317 | */ 318 | public function send(Message $message): Message 319 | { 320 | foreach ($this->connections as $connection) { 321 | if ($connection->isWritable()) { 322 | $connection->send($message); 323 | } 324 | } 325 | return $message; 326 | } 327 | 328 | 329 | /* ---------- Listener operations ------------------------------------------------------------------------------ */ 330 | 331 | /** 332 | * Start server listener. 333 | * @throws Throwable On low level error 334 | */ 335 | public function start(int|float|null $timeout = null): void 336 | { 337 | // Create socket server 338 | if (empty($this->server)) { 339 | $this->createSocketServer(); 340 | } 341 | 342 | // Check if running 343 | if ($this->running) { 344 | $this->logger->warning("[server] Server is already running"); 345 | return; 346 | } 347 | $this->running = true; 348 | $this->logger->info("[server] Server is running"); 349 | 350 | /** @var StreamCollection */ 351 | $streams = $this->streams; 352 | 353 | // Run handler 354 | while ($this->running) { 355 | try { 356 | // Clear closed connections 357 | $this->detachUnconnected(); 358 | if (is_null($this->streams)) { 359 | $this->stop(); 360 | return; 361 | } 362 | 363 | // Get streams with readable content 364 | $readables = $this->streams->waitRead($timeout ?? $this->timeout); 365 | foreach ($readables as $key => $readable) { 366 | try { 367 | $connection = null; 368 | // Accept new client connection 369 | if ($readable instanceof SocketServer) { 370 | $this->acceptSocket($readable); 371 | continue; 372 | } 373 | // Read from connection 374 | $connection = $this->connections[$key]; 375 | $message = $connection->pullMessage(); 376 | $this->dispatch($message->getOpcode(), [$this, $connection, $message]); 377 | } catch (MessageLevelInterface $e) { 378 | // Error, but keep connection open 379 | $this->logger->error("[server] {$e->getMessage()}", ['exception' => $e]); 380 | $this->dispatch('error', [$this, $connection, $e]); 381 | } catch (ConnectionLevelInterface $e) { 382 | // Error, disconnect connection 383 | if ($connection) { 384 | $this->streams()->detach($key); 385 | unset($this->connections[$key]); 386 | $connection->disconnect(); 387 | } 388 | $this->logger->error("[server] {$e->getMessage()}", ['exception' => $e]); 389 | $this->dispatch('error', [$this, $connection, $e]); 390 | } catch (CloseException $e) { 391 | // Should close 392 | if ($connection) { 393 | $connection->close($e->getCloseStatus(), $e->getMessage()); 394 | } 395 | $this->logger->error("[server] {$e->getMessage()}", ['exception' => $e]); 396 | $this->dispatch('error', [$this, $connection, $e]); 397 | } 398 | } 399 | foreach ($this->connections as $connection) { 400 | $connection->tick(); 401 | } 402 | $this->dispatch('tick', [$this]); 403 | } catch (ExceptionInterface $e) { 404 | // Low-level error 405 | $this->logger->error("[server] {$e->getMessage()}", ['exception' => $e]); 406 | $this->dispatch('error', [$this, null, $e]); 407 | } catch (Throwable $e) { 408 | // Crash it 409 | $this->logger->error("[server] {$e->getMessage()}", ['exception' => $e]); 410 | $this->disconnect(); 411 | throw $e; 412 | } 413 | gc_collect_cycles(); // Collect garbage 414 | } 415 | } 416 | 417 | /** 418 | * Stop server listener (resumable). 419 | */ 420 | public function stop(): void 421 | { 422 | $this->running = false; 423 | $this->logger->info("[server] Server is stopped"); 424 | } 425 | 426 | /** 427 | * If server is running (accepting connections and messages). 428 | * @return bool 429 | */ 430 | public function isRunning(): bool 431 | { 432 | return $this->running; 433 | } 434 | 435 | 436 | /* ---------- Connection management ---------------------------------------------------------------------------- */ 437 | 438 | /** 439 | * Orderly shutdown of server. 440 | * @param int $closeStatus Default is 1001 "Going away" 441 | */ 442 | public function shutdown(int $closeStatus = 1001): void 443 | { 444 | $this->logger->info('[server] Shutting down'); 445 | if ($this->getConnectionCount() == 0) { 446 | $this->disconnect(); 447 | return; 448 | } 449 | // Store and reset settings, lock new connections, reset listeners 450 | $max = $this->maxConnections; 451 | $this->maxConnections = 0; 452 | $listeners = $this->listeners; 453 | $this->listeners = []; 454 | // Track disconnects 455 | $this->onDisconnect(function () use ($max, $listeners) { 456 | if ($this->getConnectionCount() > 0) { 457 | return; 458 | } 459 | $this->disconnect(); 460 | // Restore settings 461 | $this->maxConnections = $max; 462 | $this->listeners = $listeners; 463 | }); 464 | // Close all current connections, listen to acks 465 | $this->close($closeStatus); 466 | $this->start(); 467 | } 468 | 469 | /** 470 | * Disconnect all connections and stop server. 471 | */ 472 | public function disconnect(): void 473 | { 474 | $this->running = false; 475 | foreach ($this->connections as $connection) { 476 | $connection->disconnect(); 477 | $this->dispatch('disconnect', [$this, $connection]); 478 | } 479 | $this->connections = []; 480 | if ($this->server) { 481 | $this->server->close(); 482 | } 483 | $this->server = $this->streams = null; 484 | $this->logger->info('[server] Server disconnected'); 485 | } 486 | 487 | 488 | /* ---------- Internal helper methods -------------------------------------------------------------------------- */ 489 | 490 | // Create socket server 491 | protected function createSocketServer(): void 492 | { 493 | try { 494 | $uri = new Uri("{$this->scheme}://0.0.0.0:{$this->port}"); 495 | $this->server = $this->streamFactory->createSocketServer($uri, $this->context); 496 | $this->streams = $this->streamFactory->createStreamCollection(); 497 | $this->streams->attach($this->server, '@server'); 498 | $this->logger->info("[server] Starting server on {$uri}."); 499 | } catch (Throwable $e) { 500 | $error = "Server failed to start: {$e->getMessage()}"; 501 | throw new ServerException($error); 502 | } 503 | } 504 | 505 | // Accept connection on socket server 506 | protected function acceptSocket(SocketServer $socket): void 507 | { 508 | if (!is_null($this->maxConnections) && $this->getConnectionCount() >= $this->maxConnections) { 509 | $this->logger->warning("[server] Denied connection, reached max {$this->maxConnections}"); 510 | return; 511 | } 512 | try { 513 | /** @var SocketStream $stream */ 514 | $stream = $socket->accept(); 515 | $name = $stream->getRemoteName(); 516 | $this->streams()->attach($stream, $name); 517 | $connection = new Connection($stream, false, true, $this->isSsl()); 518 | } catch (StreamException $e) { 519 | throw new ConnectionFailureException("Server failed to accept: {$e->getMessage()}"); 520 | } 521 | try { 522 | $connection->setLogger($this->logger); 523 | $connection 524 | ->setFrameSize($this->frameSize) 525 | ->setTimeout($this->timeout) 526 | ; 527 | foreach ($this->middlewares as $middleware) { 528 | $connection->addMiddleware($middleware); 529 | } 530 | $request = $this->performHandshake($connection); 531 | $this->connections[$name] = $connection; 532 | $this->logger->info("[server] Accepted connection from {$name}."); 533 | $this->dispatch('handshake', [ 534 | $this, 535 | $connection, 536 | $connection->getHandshakeRequest(), 537 | $connection->getHandshakeResponse(), 538 | ]); 539 | $this->dispatch('connect', [$this, $connection, $request]); 540 | } catch (ExceptionInterface | StreamException $e) { 541 | $connection->disconnect(); 542 | throw new ConnectionFailureException("Server failed to accept: {$e->getMessage()}"); 543 | } 544 | } 545 | 546 | // Detach connections no longer available 547 | protected function detachUnconnected(): void 548 | { 549 | foreach ($this->connections as $key => $connection) { 550 | if (!$connection->isConnected()) { 551 | $this->streams()->detach($key); 552 | unset($this->connections[$key]); 553 | $this->logger->info("[server] Disconnected {$key}."); 554 | $this->dispatch('disconnect', [$this, $connection]); 555 | } 556 | } 557 | } 558 | 559 | // Perform upgrade handshake on new connections. 560 | protected function performHandshake(Connection $connection): ServerRequest 561 | { 562 | $response = new Response(101); 563 | $exception = null; 564 | 565 | // Read handshake request 566 | /** @var ServerRequest */ 567 | $request = $connection->pullHttp(); 568 | 569 | // Verify handshake request 570 | try { 571 | if ($request->getMethod() != 'GET') { 572 | throw new HandshakeException( 573 | "Handshake request with invalid method: '{$request->getMethod()}'", 574 | $response->withStatus(405) 575 | ); 576 | } 577 | $connectionHeader = trim($request->getHeaderLine('Connection')); 578 | if (!str_contains(strtolower($connectionHeader), 'upgrade')) { 579 | throw new HandshakeException( 580 | "Handshake request with invalid Connection header: '{$connectionHeader}'", 581 | $response->withStatus(426) 582 | ); 583 | } 584 | $upgradeHeader = trim($request->getHeaderLine('Upgrade')); 585 | if (strtolower($upgradeHeader) != 'websocket') { 586 | throw new HandshakeException( 587 | "Handshake request with invalid Upgrade header: '{$upgradeHeader}'", 588 | $response->withStatus(426) 589 | ); 590 | } 591 | $versionHeader = trim($request->getHeaderLine('Sec-WebSocket-Version')); 592 | if ($versionHeader != '13') { 593 | throw new HandshakeException( 594 | "Handshake request with invalid Sec-WebSocket-Version header: '{$versionHeader}'", 595 | $response->withStatus(426)->withHeader('Sec-WebSocket-Version', '13') 596 | ); 597 | } 598 | $keyHeader = trim($request->getHeaderLine('Sec-WebSocket-Key')); 599 | if (empty($keyHeader)) { 600 | throw new HandshakeException( 601 | "Handshake request with invalid Sec-WebSocket-Key header: '{$keyHeader}'", 602 | $response->withStatus(426) 603 | ); 604 | } 605 | if (strlen(base64_decode($keyHeader)) != 16) { 606 | throw new HandshakeException( 607 | "Handshake request with invalid Sec-WebSocket-Key header: '{$keyHeader}'", 608 | $response->withStatus(426) 609 | ); 610 | } 611 | 612 | $responseKey = base64_encode(pack('H*', sha1($keyHeader . Constant::GUID))); 613 | $response = $response 614 | ->withHeader('Upgrade', 'websocket') 615 | ->withHeader('Connection', 'Upgrade') 616 | ->withHeader('Sec-WebSocket-Accept', $responseKey); 617 | } catch (HandshakeException $e) { 618 | $this->logger->warning("[server] {$e->getMessage()}", ['exception' => $e]); 619 | $response = $e->getResponse(); 620 | $exception = $e; 621 | } 622 | 623 | // Respond to handshake 624 | /** @var Response */ 625 | $response = $connection->pushHttp($response); 626 | if ($response->getStatusCode() != 101) { 627 | $exception = new HandshakeException("Invalid status code {$response->getStatusCode()}", $response); 628 | } 629 | 630 | if ($exception) { 631 | throw $exception; 632 | } 633 | 634 | $this->logger->debug("[server] Handshake on {$request->getUri()->getPath()}"); 635 | $connection->setHandshakeRequest($request); 636 | $connection->setHandshakeResponse($response); 637 | 638 | return $request; 639 | } 640 | 641 | protected function streams(): StreamCollection 642 | { 643 | /** @var StreamCollection $streams */ 644 | $streams = $this->streams; 645 | return $streams; 646 | } 647 | } 648 | -------------------------------------------------------------------------------- /src/Trait/ListenerTrait.php: -------------------------------------------------------------------------------- 1 | $listeners */ 35 | private array $listeners = []; 36 | 37 | /* @todo: Deprecate and remove in v4 */ 38 | /** @param Closure(T, Connection, RequestInterface|ResponseInterface): void $closure */ 39 | public function onConnect(Closure $closure): self 40 | { 41 | $msg = 'onConnect() is deprecated and will be removed in v4. Use onHandshake() instead.'; 42 | trigger_error($msg, E_USER_DEPRECATED); 43 | $this->listeners['connect'] = $closure; 44 | return $this; 45 | } 46 | 47 | /** @param Closure(T, Connection): void $closure */ 48 | public function onDisconnect(Closure $closure): self 49 | { 50 | $this->listeners['disconnect'] = $closure; 51 | return $this; 52 | } 53 | 54 | /** @param Closure(T, Connection, RequestInterface, ResponseInterface): void $closure */ 55 | public function onHandshake(Closure $closure): self 56 | { 57 | $this->listeners['handshake'] = $closure; 58 | return $this; 59 | } 60 | 61 | /** @param Closure(T, Connection, Text): void $closure */ 62 | public function onText(Closure $closure): self 63 | { 64 | $this->listeners['text'] = $closure; 65 | return $this; 66 | } 67 | 68 | /** @param Closure(T, Connection, Binary): void $closure */ 69 | public function onBinary(Closure $closure): self 70 | { 71 | $this->listeners['binary'] = $closure; 72 | return $this; 73 | } 74 | 75 | /** @param Closure(T, Connection, Ping): void $closure */ 76 | public function onPing(Closure $closure): self 77 | { 78 | $this->listeners['ping'] = $closure; 79 | return $this; 80 | } 81 | 82 | /** @param Closure(T, Connection, Pong): void $closure */ 83 | public function onPong(Closure $closure): self 84 | { 85 | $this->listeners['pong'] = $closure; 86 | return $this; 87 | } 88 | 89 | /** @param Closure(T, Connection, Close): void $closure */ 90 | public function onClose(Closure $closure): self 91 | { 92 | $this->listeners['close'] = $closure; 93 | return $this; 94 | } 95 | 96 | /** @param Closure(T, Connection|null, ExceptionInterface): void $closure */ 97 | public function onError(Closure $closure): self 98 | { 99 | $this->listeners['error'] = $closure; 100 | return $this; 101 | } 102 | 103 | /** @param Closure(T): void $closure */ 104 | public function onTick(Closure $closure): self 105 | { 106 | $this->listeners['tick'] = $closure; 107 | return $this; 108 | } 109 | 110 | /** 111 | * @param array{ 112 | * 0: T, 113 | * 1?: Connection|null, 114 | * 2?: Message|Text|Binary|Close|Ping|Pong|RequestInterface|ResponseInterface|ExceptionInterface|null, 115 | * 3?: ResponseInterface|null 116 | * } $args 117 | */ 118 | private function dispatch(string $type, array $args): void 119 | { 120 | if (array_key_exists($type, $this->listeners)) { 121 | $closure = $this->listeners[$type]; 122 | call_user_func_array($closure, $args); 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Trait/LoggerAwareTrait.php: -------------------------------------------------------------------------------- 1 | logger = $logger ?? new NullLogger(); 31 | } 32 | 33 | public function setLogger(LoggerInterface $logger): void 34 | { 35 | $this->logger = $logger; 36 | } 37 | 38 | public function attachLogger(mixed $instance): void 39 | { 40 | if ($instance instanceof LoggerAwareInterface) { 41 | $instance->setLogger($this->logger); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Trait/OpcodeTrait.php: -------------------------------------------------------------------------------- 1 | $opcodes */ 17 | private static array $opcodes = [ 18 | 'continuation' => 0, 19 | 'text' => 1, 20 | 'binary' => 2, 21 | 'close' => 8, 22 | 'ping' => 9, 23 | 'pong' => 10, 24 | ]; 25 | } 26 | -------------------------------------------------------------------------------- /src/Trait/SendMethodsTrait.php: -------------------------------------------------------------------------------- 1 | send(new Text($message)); 32 | } 33 | 34 | /** 35 | * Send binary message. 36 | * @param string $message Content as binary string. 37 | * @return Binary instance 38 | */ 39 | public function binary(string $message): Binary 40 | { 41 | return $this->send(new Binary($message)); 42 | } 43 | 44 | /** 45 | * Send ping. 46 | * @param string $message Optional text as string. 47 | * @return Ping instance 48 | */ 49 | public function ping(string $message = ''): Ping 50 | { 51 | return $this->send(new Ping($message)); 52 | } 53 | 54 | /** 55 | * Send unsolicited pong. 56 | * @param string $message Optional text as string. 57 | * @return Pong instance 58 | */ 59 | public function pong(string $message = ''): Pong 60 | { 61 | return $this->send(new Pong($message)); 62 | } 63 | 64 | /** 65 | * Tell the socket to close. 66 | * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4 67 | * @param string $message A closing message, max 125 bytes. 68 | * @return Close instance 69 | */ 70 | public function close(int $status = 1000, string $message = 'ttfn'): Close 71 | { 72 | return $this->send(new Close($status, $message)); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Trait/StringableTrait.php: -------------------------------------------------------------------------------- 1 |