diff --git a/src/Client.php b/src/Client.php index 8b35826..30f605b 100644 --- a/src/Client.php +++ b/src/Client.php @@ -11,6 +11,7 @@ use Phrity\Http\HttpFactory; use Phrity\Net\{ Context, + SocketStream, StreamCollection, StreamFactory, Uri @@ -50,15 +51,27 @@ SendMethodsTrait, StringableTrait }; +use WebSocket\Runtime\{ + HandlerInterface, + IdentityInterface, + Watcher, +}; /** * WebSocket\Client class. * Entry class for WebSocket client. */ +<<<<<<< HEAD +class Client implements HandlerInterface, IdentityInterface, LoggerAwareInterface, Stringable +{ + use ConfigurationTrait; + /** @use ListenerTrait */ +======= class Client implements IdentityInterface, LoggerAwareInterface, Stringable { use ConfigurationTrait; /** @use ListenerTrait */ +>>>>>>> v4.0-main use ListenerTrait; use SendMethodsTrait; use StringableTrait; @@ -66,18 +79,29 @@ class Client implements IdentityInterface, LoggerAwareInterface, Stringable private const SCOPE = 'client'; // Settings +<<<<<<< HEAD + /** @var array $headers */ +======= /** @var array $headers */ +>>>>>>> v4.0-main private array $headers = []; // Internal resources - private StreamFactory $streamFactory; private Uri $socketUri; private Connections $connections; /** @var array $middlewares */ private array $middlewares = []; +<<<<<<< HEAD + private bool $running = false; + + private StreamFactory $streamFactory; + private HttpFactory $httpFactory; + private Watcher $watcher; +======= private Runner $runner; private bool $running = false; private HttpFactory $httpFactory; +>>>>>>> v4.0-main /** @var non-empty-string $identity */ private string $identity = 'client/'; @@ -86,6 +110,19 @@ class Client implements IdentityInterface, LoggerAwareInterface, Stringable /** * @param UriInterface|string $uri A ws/wss-URI +<<<<<<< HEAD + * @param StreamFactory|null $streamFactory + * @param HttpFactory|null $httpFactory + * @param Watcher|null $watcher + * @param Configuration|null $configuration + */ + public function __construct( + UriInterface|string $uri, + StreamFactory|null $streamFactory = null, + HttpFactory|null $httpFactory = null, + Watcher|null $watcher = null, + Configuration|null $configuration = null, +======= * @param Configuration|null $configuration * @param StreamFactory|null $streamFactory * @param Runner|null $runner @@ -96,15 +133,22 @@ public function __construct( StreamFactory|null $streamFactory = null, HttpFactory|null $httpFactory = null, Runner|null $runner = null, +>>>>>>> v4.0-main ) { $this->socketUri = $this->parseUri($uri); $this->streamFactory = $streamFactory ?? new StreamFactory(); $this->httpFactory = $httpFactory ?? new DefaultHttpFactory(); +<<<<<<< HEAD + $this->watcher = $watcher ?? new Watcher($this->streamFactory->createStreamCollection()); + $this->initConfiguration($configuration); + $this->identity = "client/{$this->socketUri->getHost()}"; +======= $this->identity = "client/{$this->socketUri->getHost()}"; $this->initConfiguration($configuration); $this->runner = $runner ?? new Runner($this->streamFactory); $this->connections = new Connections(true, false, $this->httpFactory, $this->configuration); +>>>>>>> v4.0-main } /** @@ -116,6 +160,11 @@ public function __toString(): string return $this->stringable('%s', $this->connections->isEmpty() ? 'closed' : $this->socketUri->__toString()); } + public function getIdentity(): string + { + return $this->identity; + } + /* ---------- Configuration ------------------------------------------------------------------------------------ */ @@ -135,6 +184,17 @@ public function setHttpFactory(HttpFactory $httpFactory): self return $this; } + /** + * Set HTTP factory to use. + * @param HttpFactory $httpFactory + * @return self + */ + public function setHttpFactory(HttpFactory $httpFactory): self + { + $this->httpFactory = $httpFactory; + return $this; + } + /** * Set logger. * @param LoggerInterface $logger Logger implementation @@ -149,8 +209,11 @@ public function setLogger(LoggerInterface $logger): void * Set timeout. * @param int<0, max>|float $timeout Timeout in seconds * @return self +<<<<<<< HEAD +======= * @throws InvalidArgumentException If invalid timeout provided * @deprecated Will be removed in future version, set on Configuration instead +>>>>>>> v4.0-main */ public function setTimeout(int|float $timeout): self { @@ -172,8 +235,11 @@ public function getTimeout(): int|float * Set frame size. * @param int<1, max> $frameSize Max frame payload size in bytes * @return self +<<<<<<< HEAD +======= * @throws InvalidArgumentException If invalid frameSize provided * @deprecated Will be removed in future version, set on Configuration instead +>>>>>>> v4.0-main */ public function setFrameSize(int $frameSize): self { @@ -205,7 +271,11 @@ public function setPersistent(bool $persistent): self /** * Set stream context. +<<<<<<< HEAD + * @param Context $context Context or options as array +======= * @param Context $context Context +>>>>>>> v4.0-main * @see https://www.php.net/manual/en/context.php * @return self * @deprecated Will be removed in future version, set on Configuration instead @@ -288,17 +358,27 @@ public function start(int|float|null $timeout = null): void { // Check if running if ($this->running) { +<<<<<<< HEAD + $this->configuration->getLogger()->warning('[{identity}] Client is already running', [ + 'identity' => $this->identity, +======= $this->configuration->getLogger()->warning("[{scope}] Client is already running", [ 'scope' => self::SCOPE, 'client' => $this->identity, +>>>>>>> v4.0-main ]); return; } $this->running = true; $reconnect = false; +<<<<<<< HEAD + $this->configuration->getLogger()->info('[{identity}] Client is running', [ + 'identity' => $this->identity, +======= $this->configuration->getLogger()->info("[{scope}] Client is running", [ 'scope' => self::SCOPE, 'client' => $this->identity, +>>>>>>> v4.0-main ]); $connection = $this->connection(); @@ -306,9 +386,13 @@ public function start(int|float|null $timeout = null): void // Run handler while ($this->running) { try { +<<<<<<< HEAD + $this->watcher->watch($timeout ?? $this->configuration->getTimeout()); +======= // Run attached handlers on selected streams $this->runner->handle($timeout ?? $this->configuration->getTimeout()); +>>>>>>> v4.0-main if (!$connection->isConnected()) { $this->running = false; } @@ -317,12 +401,19 @@ public function start(int|float|null $timeout = null): void } catch (CloseException $e) { // Close connection $connection->close($e->getCloseStatus(), $e->getMessage()); +<<<<<<< HEAD + $this->configuration->getLogger()->error('[{identity}] {error} (close)', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), +======= $this->configuration->getLogger()->error("[{scope}] {message}", [ 'scope' => self::SCOPE, 'client' => $this->identity, 'connection' => $connection->getIdentity(), 'exception' => $e, 'message' => $e->getMessage(), +>>>>>>> v4.0-main ]); $this->dispatch('error', [$this, $connection, $e]); } catch (ReconnectException $e) { @@ -332,38 +423,57 @@ public function start(int|float|null $timeout = null): void $this->socketUri = $uri; } $connection->close(); +<<<<<<< HEAD + $this->configuration->getLogger()->error('[{identity}] {error} (reconnect)', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), +======= $this->configuration->getLogger()->error("[{scope}] {message}", [ 'scope' => self::SCOPE, 'client' => $this->identity, 'connection' => $connection->getIdentity(), 'exception' => $e, 'message' => $e->getMessage(), +>>>>>>> v4.0-main ]); $this->dispatch('error', [$this, $connection, $e]); } catch (ExceptionInterface $e) { $this->disconnect(); $this->running = false; - // Low-level error +<<<<<<< HEAD + $this->configuration->getLogger()->error('[{identity}] {error}', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), +======= $this->configuration->getLogger()->error("[{scope}] {message}", [ 'scope' => self::SCOPE, 'client' => $this->identity, 'connection' => $connection->getIdentity(), 'exception' => $e, 'message' => $e->getMessage(), +>>>>>>> v4.0-main ]); $this->dispatch('error', [$this, null, $e]); } catch (Throwable $e) { $this->disconnect(); $this->running = false; - // Crash it +<<<<<<< HEAD + $this->configuration->getLogger()->error('[{identity}] {error}', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), +======= $this->configuration->getLogger()->error("[{scope}] {message}", [ 'scope' => self::SCOPE, 'client' => $this->identity, 'connection' => $connection->getIdentity(), 'exception' => $e, 'message' => $e->getMessage(), +>>>>>>> v4.0-main ]); throw $e; } @@ -376,15 +486,46 @@ public function start(int|float|null $timeout = null): void } } + public function selectHandler(Connection $connection): void + { + try { + // Read from connection + $message = $connection->pullMessage(); + $this->dispatch($message->getOpcode(), [$this, $connection, $message]); + } catch (MessageLevelInterface $e) { + // Error, but keep connection open + $this->configuration->getLogger()->error('[{identity}] {error} (message)', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), + ]); + $this->dispatch('error', [$this, $connection, $e]); + } catch (ConnectionLevelInterface $e) { + // Error, disconnect connection + $this->disconnect(); + $this->configuration->getLogger()->error('[{identity}] {error} (connection)', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), + ]); + $this->dispatch('error', [$this, $connection, $e]); + } + } + /** * Stop client listener (resumable). */ public function stop(): void { $this->running = false; +<<<<<<< HEAD + $this->configuration->getLogger()->info('[{identity}] Client is stopped', [ + 'identity' => $this->identity, +======= $this->configuration->getLogger()->info("[{scope}] Client is stopped", [ 'scope' => self::SCOPE, 'client' => $this->identity, +>>>>>>> v4.0-main ]); } @@ -400,6 +541,11 @@ public function isRunning(): bool /* ---------- Connection management ---------------------------------------------------------------------------- */ + public function getConnection(): Connection|null + { + return $this->connection; + } + /** * If Client has active connection. * @return bool True if active connection. @@ -437,13 +583,20 @@ public function isWritable(): bool public function connect(): void { $this->disconnect(); +<<<<<<< HEAD +======= $this->connections->reset(); +>>>>>>> v4.0-main $hostUri = (new Uri()) ->withScheme(match ($this->socketUri->getScheme()) { 'ws', 'http' => 'tcp', 'wss', 'https' => 'ssl', +<<<<<<< HEAD + default => throw new ClientException($this, "Invalid socket scheme: {$this->socketUri->getScheme()}") +======= default => throw new ClientException('Invalid scheme on {uri}', uri: $this->socketUri) +>>>>>>> v4.0-main }) ->withHost($this->socketUri->getHost(Uri::IDN_ENCODE)) ->withPort($this->socketUri->getPort(Uri::REQUIRE_PORT)); @@ -456,6 +609,26 @@ public function connect(): void $client->setTimeout($this->configuration->getTimeout()); $stream = $client->connect(); } catch (Throwable $e) { +<<<<<<< HEAD + $error = "Could not open socket to \"{$hostUri}\": {$e->getMessage()}"; + $this->configuration->getLogger()->error('[{identity}] {error}', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $error, + ]); + throw new ClientException($this, $error, $e); + } + $this->connection = new Connection( + $this, + $stream, + true, + false, + $hostUri->getScheme() === 'ssl', + $this->httpFactory, + $this->configuration, + ); + $this->watcher->attach($this->connection); +======= $this->configuration->getLogger()->error("[{scope}] {message}", [ 'scope' => self::SCOPE, 'client' => $this->identity, @@ -496,11 +669,30 @@ public function connect(): void $this->dispatch('error', [$this, $connection, $e]); } }, $connection->getIdentity()); +>>>>>>> v4.0-main foreach ($this->middlewares as $middleware) { $connection->addMiddleware($middleware); } +<<<<<<< HEAD + + $this->watcher->attach($this->connection->getIdentity(), $stream, function (string $key, SocketStream $stream) { + $this->selectHandler($key, $stream); + }); + + if (is_null($this->connection) || !$this->isConnected()) { + $error = "Invalid stream on \"{$hostUri}\"."; + $this->configuration->getLogger()->error('[{identity}] {error}', [ + 'identity' => $this->identity, + 'error' => $error, + ]); + $this->disconnect(); + throw new ClientException($this, $error); + } + foreach ($this->middlewares as $middleware) { + $this->connection->addMiddleware($middleware); +======= if (!$this->isConnected()) { $this->configuration->getLogger()->error("[{scope}] Invalid stream on {uri}", [ 'scope' => self::SCOPE, @@ -509,6 +701,7 @@ public function connect(): void 'uri' => $hostUri, ]); throw new ClientException('Invalid stream on {uri}', uri: $hostUri); +>>>>>>> v4.0-main } try { if (!$this->configuration->isPersistent() || $stream->tell() == 0) { @@ -516,12 +709,19 @@ public function connect(): void $response = $this->performHandshake($this->socketUri, $connection); } } catch (ReconnectException $e) { +<<<<<<< HEAD + $this->configuration->getLogger()->error('[{identity}] {error}', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), +======= $this->configuration->getLogger()->info("[{scope}] {message}", [ 'scope' => self::SCOPE, 'client' => $this->identity, 'connection' => $connection->getIdentity(), 'exception' => $e, 'message' => $e->getMessage(), +>>>>>>> v4.0-main ]); if ($uri = $e->getUri()) { $this->socketUri = $uri; @@ -529,12 +729,20 @@ public function connect(): void $this->connect(); return; } +<<<<<<< HEAD + $this->configuration->getLogger()->info('[{identity}] Client connected to {uri}', [ + 'identity' => $this->identity, + 'uri' => $this->socketUri, + ]); + +======= $this->configuration->getLogger()->info("[{scope}] Client connected to {uri}", [ 'scope' => self::SCOPE, 'client' => $this->identity, 'connection' => $connection->getIdentity(), 'uri' => $this->socketUri, ]); +>>>>>>> v4.0-main $this->dispatch('handshake', [ $this, $connection, @@ -549,9 +757,21 @@ public function connect(): void */ public function disconnect(): void { +<<<<<<< HEAD + if ($this->connection) { + $this->watcher->detach($this->connection->getIdentity()); + } + if ($this->connection && $this->isConnected()) { + $this->connection->disconnect(); + $this->configuration->getLogger()->info('[{identity}] Client disconnected', [ + 'identity' => $this->identity, + ]); + $this->dispatch('disconnect', [$this, $this->connection]); +======= $connection = $this->connections->first(); if ($connection === null) { return; +>>>>>>> v4.0-main } $this->runner->detach($connection->getIdentity()); $this->connections->detach($connection->getIdentity()); @@ -585,8 +805,12 @@ public function getName(): string|null */ public function getRemoteName(): string|null { +<<<<<<< HEAD + return $this->isConnected() ? $this->connection?->getRemoteName() : null; +======= $connection = $this->connections->first(); return $connection && $this->isConnected() ? $connection->getRemoteName() : null; +>>>>>>> v4.0-main } /** @@ -600,6 +824,18 @@ public function getHandshakeResponse(): ResponseInterface|null } + public function beforeStart(): void + { + } + + public function beforeWatch(): void + { + } + + public function afterWatch(): void + { + } + /* ---------- Internal helper methods -------------------------------------------------------------------------- */ /** @@ -637,17 +873,29 @@ protected function performHandshake(Uri $uri, Connection $connection): ResponseI $response = $connection->pullHttp(); if ($response->getStatusCode() != 101) { throw new HandshakeException( +<<<<<<< HEAD + $connection, + $response, + "Invalid status code {$response->getStatusCode()}.", +======= 'Invalid status code {statusCode}', response: $response, statusCode: $response->getStatusCode(), +>>>>>>> v4.0-main ); } if (empty($response->getHeaderLine('Sec-WebSocket-Accept'))) { throw new HandshakeException( +<<<<<<< HEAD + $connection, + $response, + "Connection to '{$uri}' failed: Server sent invalid upgrade response.", +======= 'Connection to {uri} failed: Invalid upgrade response', uri: $uri, response: $response, +>>>>>>> v4.0-main ); } @@ -658,6 +906,18 @@ protected function performHandshake(Uri $uri, Connection $connection): ResponseI if ($responseKey !== $expectedKey) { throw new HandshakeException( +<<<<<<< HEAD + $connection, + $response, + "Server sent bad upgrade response.", + ); + } + } catch (HandshakeException $e) { + $this->configuration->getLogger()->error('[{identity}] {error} (handshake)', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), +======= 'Server sent bad upgrade response', response: $response, ); @@ -669,10 +929,17 @@ protected function performHandshake(Uri $uri, Connection $connection): ResponseI 'connection' => $connection->getIdentity(), 'exception' => $e, 'message' => $e->getMessage(), +>>>>>>> v4.0-main ]); throw $e; } + $this->configuration->getLogger()->debug('[{identity}] Handshake on {path}', [ + 'identity' => $this->identity, + 'path' => $uri->getPath(), + ]); +<<<<<<< HEAD +======= $this->configuration->getLogger()->debug("[{scope}] Handshake on {path}", [ 'scope' => self::SCOPE, 'client' => $this->identity, @@ -680,6 +947,7 @@ protected function performHandshake(Uri $uri, Connection $connection): ResponseI 'path' => $uri->getPath(), ]); +>>>>>>> v4.0-main $connection->setHandshakeRequest($request); $connection->setHandshakeResponse($response); @@ -716,14 +984,14 @@ protected function parseUri(UriInterface|string $uri): Uri $uriInstance = new Uri($uri); } } catch (InvalidArgumentException $e) { - throw new BadUriException("Invalid URI '{$uri}' provided."); + throw new BadUriException($this, "Invalid URI '{$uri}' provided."); } if (!in_array($uriInstance->getScheme(), ['ws', 'wss'])) { - throw new BadUriException("Invalid URI scheme, must be 'ws' or 'wss'."); + throw new BadUriException($this, "Invalid URI scheme, must be 'ws' or 'wss'."); } if (!$uriInstance->getHost()) { - throw new BadUriException("Invalid URI host."); + throw new BadUriException($this, "Invalid URI host."); } return $uriInstance->withPath($uriInstance->getPath(), Uri::ABSOLUTE_PATH | Uri::NORMALIZE_PATH); } diff --git a/src/Cluster.php b/src/Cluster.php new file mode 100644 index 0000000..596c75c --- /dev/null +++ b/src/Cluster.php @@ -0,0 +1,177 @@ + */ + use ListenerTrait; + use StringableTrait; + + private StreamFactory $streamFactory; + private Watcher $watcher; + + /** @var array $services */ + private array $services = []; + private bool $running = false; + + + /* ---------- Magic methods ------------------------------------------------------------------------------------ */ + + /** + * @param StreamFactory|null $streamFactory + * @param Watcher|null $watcher + */ + public function __construct( + StreamFactory|null $streamFactory = null, + Watcher|null $watcher = null, + Configuration|null $configuration = null, + ) { + $this->streamFactory = $streamFactory ?? new StreamFactory(); + $this->watcher = $watcher ?? new Watcher($this->streamFactory->createStreamCollection()); + $this->initConfiguration($configuration); + } + + /** + * Get string representation of instance. + * @return string String representation + */ + public function __toString(): string + { + return $this->stringable(); + } + + /** + * @param int<0, 65535> $port Socket port to listen to + * @param bool $ssl If SSL should be used + * @param string|null $name + * @throws ServerException If already attached + */ + public function createServer(int $port = 80, bool $ssl = false, string|null $name = null): Server + { + $name = $name ?? "server:{$port}"; + if (array_key_exists($name, $this->services)) { + throw new ServerException(null, "Server {$name} already attached."); + } + $server = new Server( + port: $port, + ssl: $ssl, + streamFactory: $this->streamFactory, + watcher: $this->watcher, + configuration: $this->configuration, + ); + $this->services[$name] = $server; + return $server; + } + + /** + * Set logger. + * @param LoggerInterface $logger Logger implementation + */ + public function setLogger(LoggerInterface $logger): void + { + $this->configuration->setLogger($logger); + } + + public function start(int|float|null $timeout = null): void + { + // Check if running + if ($this->running) { + $this->configuration->getLogger()->warning("[CLUSTER] Cluster is already running"); + return; + } + $this->beforeStart(); + $this->running = true; + $this->configuration->getLogger()->info("[CLUSTER] Cluster is running"); + + // Run handler + while ($this->running) { + try { + $this->beforeWatch(); + if ($this->watcher->isEmpty()) { + $this->stop(); + return; + } + $this->watcher->watch($timeout ?? $this->configuration->getTimeout()); + $this->afterWatch(); + } catch (ExceptionInterface $e) { + // Low-level error + $this->configuration->getLogger()->error("[CLUSTER] {$e->getMessage()}", ['exception' => $e]); + $this->dispatch('error', [$this, null, $e]); + } catch (Throwable $e) { + // Crash it + $this->configuration->getLogger()->error("[CLUSTER] {$e->getMessage()}", ['exception' => $e]); + foreach ($this->services as $service) { + $service->disconnect(); + } + throw $e; + } + gc_collect_cycles(); // Collect garbage + } + } + + public function stop(): void + { + $this->running = false; + $this->configuration->getLogger()->info("[CLUSTER] Cluster is stopped"); + } + + private function beforeStart(): void + { + foreach ($this->services as $service) { + $service->beforeStart(); + } + } + + private function beforeWatch(): void + { + foreach ($this->services as $service) { + $service->beforeWatch(); + } + } + + private function afterWatch(): void + { + foreach ($this->services as $service) { + $service->afterWatch(); + } + } +} diff --git a/src/Connection.php b/src/Connection.php index 4ef6b0a..dd92a29 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -13,14 +13,20 @@ Context, SocketStream, StreamContainerInterface, +<<<<<<< HEAD + StreamInterface, +======= +>>>>>>> v4.0-main }; use Psr\Http\Message\{ MessageInterface, RequestInterface, - ResponseFactoryInterface, ResponseInterface, +<<<<<<< HEAD +======= ServerRequestFactoryInterface, UriFactoryInterface, +>>>>>>> v4.0-main }; use Stringable; use Throwable; @@ -41,7 +47,15 @@ MiddlewareHandler, MiddlewareInterface }; +<<<<<<< HEAD +use WebSocket\Runtime\{ + HandlerInterface, + SelectableInterface, + IdentityInterface, +}; +======= use WebSocket\Runtime\IdentityInterface; +>>>>>>> v4.0-main use WebSocket\Trait\{ ConfigurationTrait, SendMethodsTrait, @@ -52,14 +66,22 @@ * WebSocket\Connection class. * A client/server connection, wrapping socket stream. */ +<<<<<<< HEAD +class Connection implements IdentityInterface, SelectableInterface. Stringable +======= class Connection implements IdentityInterface, StreamContainerInterface, Stringable +>>>>>>> v4.0-main { use ConfigurationTrait; use SendMethodsTrait; use StringableTrait; +<<<<<<< HEAD + private HandlerInterface $handler; +======= private const SCOPE = 'connection'; +>>>>>>> v4.0-main private SocketStream $stream; private HttpHandler $httpHandler; private MessageHandler $messageHandler; @@ -71,12 +93,17 @@ class Connection implements IdentityInterface, StreamContainerInterface, Stringa /** @var array $meta */ private array $meta = []; /** @var non-empty-string $identity */ +<<<<<<< HEAD + private string $identity = 'client/'; +======= private string $identity = '*/connection/'; +>>>>>>> v4.0-main /* ---------- Magic methods ------------------------------------------------------------------------------------ */ public function __construct( + HandlerInterface $handler, SocketStream $stream, bool $pushMasked, bool $pullMaskedRequired, @@ -84,10 +111,30 @@ public function __construct( HttpFactory|null $httpFactory = null, Configuration|null $configuration = null, ) { + $this->handler = $handler; $this->stream = $stream; $this->initConfiguration($configuration); $this->httpHandler = new HttpHandler($this->stream, $ssl, $httpFactory); $this->messageHandler = new MessageHandler( +<<<<<<< HEAD + new FrameHandler($this->stream, $pushMasked, $pullMaskedRequired, $this->configuration), + $this->configuration + ); + $this->middlewareHandler = new MiddlewareHandler( + $this->messageHandler, + $this->httpHandler, + $this->configuration + ); + $this->localName = $this->stream->getLocalName() ?? ''; + $this->remoteName = $this->stream->getRemoteName() ?? ''; + $this->stream->setTimeout($this->configuration->getTimeout()); + $this->identity = sprintf( + '%s/connection/%s/%s', + $this->handler->getIdentity(), + $this->getIdentityPart($this->localName), + $this->getIdentityPart($this->remoteName), + ); +======= new FrameHandler($this->stream, $pushMasked, $pullMaskedRequired), $this->configuration ); @@ -100,6 +147,7 @@ public function __construct( $this->getIdentityPart($this->remoteName), ); $this->stream->setTimeout($this->configuration->getTimeout()); +>>>>>>> v4.0-main } public function __toString(): string @@ -107,14 +155,27 @@ public function __toString(): string return $this->stringable('%s:%s', $this->localName, $this->remoteName); } + public function getHandler(): HandlerInterface + { + return $this->handler; + } + + public function getIdentity(): string + { + return $this->identity; + } + /* ---------- Configuration ------------------------------------------------------------------------------------ */ +<<<<<<< HEAD +======= public function getIdentity(): string { return $this->identity; } +>>>>>>> v4.0-main /** * Get current stream context. * @return Context @@ -132,10 +193,16 @@ public function getContext(): Context public function addMiddleware(MiddlewareInterface $middleware): self { $this->middlewareHandler->add($middleware); +<<<<<<< HEAD + $this->configuration->getLogger()->debug('[{identity}] Added middleware: {middleware}', [ + 'identity' => $this->identity, + 'middleware' => (string)$middleware, +======= $this->configuration->getLogger()->debug("[{scope}] Added middleware: {middleware}", [ 'scope' => self::SCOPE, 'connection' => $this->identity, 'middleware' => $middleware, +>>>>>>> v4.0-main ]); return $this; } @@ -176,9 +243,14 @@ public function isWritable(): bool */ public function disconnect(): self { +<<<<<<< HEAD + $this->configuration->getLogger()->info('[{identity}] Closing connection', [ + 'identity' => $this->identity, +======= $this->configuration->getLogger()->info("[{scope}] Closing connection", [ 'scope' => self::SCOPE, 'connection' => $this->identity, +>>>>>>> v4.0-main ]); $this->stream->close(); return $this; @@ -190,9 +262,14 @@ public function disconnect(): self */ public function closeRead(): self { +<<<<<<< HEAD + $this->configuration->getLogger()->info('[{identity}] Closing further reading', [ + 'identity' => $this->identity, +======= $this->configuration->getLogger()->info("[{scope}] Closing further reading", [ 'scope' => self::SCOPE, 'connection' => $this->identity, +>>>>>>> v4.0-main ]); $this->stream->closeRead(); return $this; @@ -204,9 +281,14 @@ public function closeRead(): self */ public function closeWrite(): self { +<<<<<<< HEAD + $this->configuration->getLogger()->info('[{identity}] Closing further writing', [ + 'identity' => $this->identity, +======= $this->configuration->getLogger()->info("[{scope}] Closing further writing", [ 'scope' => self::SCOPE, 'connection' => $this->identity, +>>>>>>> v4.0-main ]); $this->stream->closeWrite(); return $this; @@ -217,18 +299,18 @@ public function closeWrite(): self /** * Get name of local socket, or null if not connected. - * @return string|null + * @return string */ - public function getName(): string|null + public function getName(): string { return $this->localName; } /** * Get name of remote socket, or null if not connected. - * @return string|null + * @return string */ - public function getRemoteName(): string|null + public function getRemoteName(): string { return $this->remoteName; } @@ -350,11 +432,22 @@ public function getHandshakeResponse(): ResponseInterface|null return $this->handshakeResponse; } +<<<<<<< HEAD + public function getStream(): StreamInterface +======= public function getStream(): SocketStream +>>>>>>> v4.0-main { return $this->stream; } +<<<<<<< HEAD + public function onSelect(): void + { + $this->getHandler()->selectHandler($this); + } +======= +>>>>>>> v4.0-main /* ---------- Internal helper methods -------------------------------------------------------------------------- */ @@ -369,20 +462,34 @@ protected function throwException(Throwable $e): never { // Internal exceptions are handled and re-thrown if ($e instanceof ReconnectException) { +<<<<<<< HEAD + $this->configuration->getLogger()->info('[{identity}] {error}', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), +======= $this->configuration->getLogger()->info("[{scope}] {message}", [ 'scope' => self::SCOPE, 'connection' => $this->identity, 'exception' => $e, 'message' => $e->getMessage(), +>>>>>>> v4.0-main ]); throw $e; } if ($e instanceof ExceptionInterface) { +<<<<<<< HEAD + $this->configuration->getLogger()->error('[{identity}] {error}', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), +======= $this->configuration->getLogger()->error("[{scope}] {message}", [ 'scope' => self::SCOPE, 'connection' => $this->identity, 'exception' => $e, 'message' => $e->getMessage(), +>>>>>>> v4.0-main ]); throw $e; } @@ -391,16 +498,47 @@ protected function throwException(Throwable $e): never $meta = $this->stream->getMetadata(); $json = json_encode($meta); if (!empty($meta['timed_out'])) { +<<<<<<< HEAD + $this->configuration->getLogger()->error('[{identity}] {error}', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), + 'meta' => $meta, +======= $this->configuration->getLogger()->error("[{scope}] {message}", [ 'scope' => self::SCOPE, 'connection' => $this->identity, 'exception' => $e, 'message' => $e->getMessage(), 'meta' => $meta +>>>>>>> v4.0-main ]); throw new ConnectionTimeoutException(); } if (!empty($meta['eof'])) { +<<<<<<< HEAD + $this->configuration->getLogger()->error('[{identity}] {error}', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), + 'meta' => $meta, + ]); + throw new ConnectionClosedException($this, null, $e); + } + } + $this->configuration->getLogger()->error('[{identity}] {error}', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), + ]); + throw new ConnectionFailureException($this, null, $e); + } + + protected function getIdentityPart(string $source): string + { + preg_match('/([0-9]+)$/', $source, $result); + return empty($result) ? $source : array_shift($result); +======= $this->configuration->getLogger()->error("[{scope}] {message}", [ 'scope' => self::SCOPE, 'connection' => $this->identity, @@ -418,6 +556,7 @@ protected function throwException(Throwable $e): never 'message' => $e->getMessage(), ]); throw new ConnectionFailureException(); +>>>>>>> v4.0-main } protected function getIdentityPart(string $source): string diff --git a/src/Exception/AbstractConnectionException.php b/src/Exception/AbstractConnectionException.php new file mode 100644 index 0000000..c052344 --- /dev/null +++ b/src/Exception/AbstractConnectionException.php @@ -0,0 +1,42 @@ +connection = $connection; + parent::__construct($message, $previous); + } + + public function getHandler(): HandlerInterface|null + { + return $this->getConnection()?->getHandler(); + } + + public function getConnection(): Connection|null + { + return $this->connection; + } +} diff --git a/src/Exception/AbstractHandlerException.php b/src/Exception/AbstractHandlerException.php new file mode 100644 index 0000000..da3f24a --- /dev/null +++ b/src/Exception/AbstractHandlerException.php @@ -0,0 +1,37 @@ +handler = $handler; + parent::__construct($message, $previous); + } + + public function getHandler(): HandlerInterface|null + { + return $this->handler; + } +} diff --git a/src/Exception/AbstractMessageException.php b/src/Exception/AbstractMessageException.php new file mode 100644 index 0000000..32a0693 --- /dev/null +++ b/src/Exception/AbstractMessageException.php @@ -0,0 +1,19 @@ +streamCollection = $streamCollection; + } + + public function attach(SelectableInterface $attach): void + { + $this->streamCollection->attach($attach, $attach->getIdentity()); + } + + public function detach(string $key): void + { + $this->streamCollection->detach($key); + } + + /** + * @throws ExceptionInterface + * @throws StreamException + */ + public function watch(float $timeout): void + { + $readables = $this->streamCollection->waitRead($timeout); + foreach ($readables as $key => $readable) { + if ($readable instanceof SelectableInterface) { + $readable->onSelect(); + } + } + } + + public function isEmpty(): bool + { + return $this->streamCollection->count() == 0; + } +} diff --git a/src/Server.php b/src/Server.php index 37ba548..9b88db7 100644 --- a/src/Server.php +++ b/src/Server.php @@ -20,10 +20,13 @@ StreamInterface, Uri }; +<<<<<<< HEAD +======= use Psr\Http\Message\{ ResponseInterface, ServerRequestInterface, }; +>>>>>>> v4.0-main use Psr\Log\{ LoggerAwareInterface, LoggerInterface, @@ -53,15 +56,28 @@ SendMethodsTrait, StringableTrait }; +use WebSocket\Runtime\{ + HandlerInterface, + SelectableInterface, + IdentityInterface, + Watcher, +}; /** * WebSocket\Server class. * Entry class for WebSocket server. */ +<<<<<<< HEAD +class Server implements HandlerInterface, IdentityInterface, LoggerAwareInterface, SelectableInterface, Stringable +{ + use ConfigurationTrait; + /** @use ListenerTrait */ +======= class Server implements IdentityInterface, LoggerAwareInterface, StreamContainerInterface, Stringable { use ConfigurationTrait; /** @use ListenerTrait */ +>>>>>>> v4.0-main use ListenerTrait; use SendMethodsTrait; use StringableTrait; @@ -73,16 +89,25 @@ class Server implements IdentityInterface, LoggerAwareInterface, StreamContainer private string $scheme; // Internal resources - private StreamFactory $streamFactory; private SocketServer|null $server = null; +<<<<<<< HEAD +======= private Runner $runner; +>>>>>>> v4.0-main private bool $running = false; private Connections $connections; /** @var array $middlewares */ private array $middlewares = []; private bool $allowConnections = false; +<<<<<<< HEAD + + private StreamFactory $streamFactory; + private HttpFactory $httpFactory; + private Watcher $watcher; +======= private HttpFactory $httpFactory; +>>>>>>> v4.0-main /** @var non-empty-string $identity */ private string $identity; @@ -90,21 +115,35 @@ class Server implements IdentityInterface, LoggerAwareInterface, StreamContainer /* ---------- Magic methods ------------------------------------------------------------------------------------ */ /** - * @param int $port Socket port to listen to + * @param int<0, 65535> $port Socket port to listen to * @param bool $ssl If SSL should be used +<<<<<<< HEAD + * @param StreamFactory|null $streamFactory + * @param HttpFactory|null $httpFactory + * @param Watcher|null $watcher + * @param Configuration|null $configuration +======= * @param Configuration|null $configuration * @param StreamFactory|null $streamFactory * @param HttpFactory|null $httpFactory * @param Runner|null $runner +>>>>>>> v4.0-main * @throws InvalidArgumentException If invalid port provided */ public function __construct( int $port = 80, bool $ssl = false, +<<<<<<< HEAD + StreamFactory|null $streamFactory = null, + HttpFactory|null $httpFactory = null, + Watcher|null $watcher = null, + Configuration|null $configuration = null, +======= Configuration|null $configuration = null, StreamFactory|null $streamFactory = null, HttpFactory|null $httpFactory = null, Runner|null $runner = null, +>>>>>>> v4.0-main ) { if ($port < 0 || $port > 65535) { throw new InvalidArgumentException("Invalid port '{$port}' provided"); @@ -113,10 +152,16 @@ public function __construct( $this->scheme = $ssl ? 'ssl' : 'tcp'; $this->streamFactory = $streamFactory ?? new StreamFactory(); $this->httpFactory = $httpFactory ?? new DefaultHttpFactory(); +<<<<<<< HEAD + $this->watcher = $watcher ?? new Watcher($this->streamFactory->createStreamCollection()); + $this->initConfiguration($configuration); + $this->identity = "server/{$port}"; +======= $this->identity = "server/{$port}"; $this->initConfiguration($configuration); $this->runner = $runner ?? new Runner($this->streamFactory); $this->connections = new Connections(false, true, $this->httpFactory, $this->configuration); +>>>>>>> v4.0-main } /** @@ -128,6 +173,11 @@ public function __toString(): string return $this->stringable('%s', $this->server ? "{$this->scheme}://0.0.0.0:{$this->port}" : 'closed'); } + public function getIdentity(): string + { + return $this->identity; + } + /* ---------- Configuration ------------------------------------------------------------------------------------ */ @@ -147,6 +197,17 @@ public function setHttpFactory(HttpFactory $httpFactory): self return $this; } + /** + * Set HTTP factory to use. + * @param HttpFactory $httpFactory + * @return self + */ + public function setHttpFactory(HttpFactory $httpFactory): self + { + $this->httpFactory = $httpFactory; + return $this; + } + /** * Set logger. * @param LoggerInterface $logger Logger implementation @@ -182,10 +243,13 @@ public function getTimeout(): int|float /** * Set frame size. - * @param int<1, max> $frameSize Frame size in bytes + * @param int<1, max> $frameSize Max frame payload size in bytes * @return self +<<<<<<< HEAD +======= * @throws InvalidArgumentException If invalid frameSize provided * @deprecated Will be removed in future version, set on Configuration instead +>>>>>>> v4.0-main */ public function setFrameSize(int $frameSize): self { @@ -272,7 +336,11 @@ public function getWritableConnections(): array /** * Set stream context. +<<<<<<< HEAD + * @param Context $context Context or options as array +======= * @param Context $context Context +>>>>>>> v4.0-main * @see https://www.php.net/manual/en/context.php * @return self * @deprecated Will be removed in future version, set on Configuration instead @@ -311,8 +379,11 @@ public function addMiddleware(MiddlewareInterface $middleware): self * Set maximum number of connections allowed, null means unlimited. * @param int<1, max>|null $maxConnections * @return self +<<<<<<< HEAD +======= * @throws InvalidArgumentException If number provided * @deprecated Will be removed in future version, set on Configuration instead +>>>>>>> v4.0-main */ public function setMaxConnections(int|null $maxConnections): self { @@ -344,32 +415,52 @@ public function send(Message $message): Message /** * Start server listener. - * @throws Throwable On low level error */ public function start(int|float|null $timeout = null): void { - // Create socket server - if (empty($this->server)) { - $this->createSocketServer(); - } - // Check if running if ($this->running) { +<<<<<<< HEAD + $this->configuration->getLogger()->warning('[{identity}] Server is already running', [ + 'identity' => $this->identity, +======= $this->configuration->getLogger()->warning("[{scope}] Server is already running", [ 'scope' => self::SCOPE, 'server' => $this->identity, +>>>>>>> v4.0-main ]); return; } + $this->beforeStart(); $this->running = true; +<<<<<<< HEAD + $this->configuration->getLogger()->info('[{identity}] Server is running', [ + 'identity' => $this->identity, +======= $this->configuration->getLogger()->info("[{scope}] Server is running", [ 'scope' => self::SCOPE, 'server' => $this->identity, +>>>>>>> v4.0-main ]); // Run handler while ($this->running) { try { +<<<<<<< HEAD + $this->beforeWatch(); + if ($this->watcher->isEmpty()) { + $this->stop(); + return; + } + $this->watcher->watch($timeout ?? $this->configuration->getTimeout()); + $this->afterWatch(); + } catch (ExceptionInterface $e) { + // Low-level error + $this->configuration->getLogger()->error('[{identity}] {error}', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), +======= // Clear closed connections $this->detachUnconnected(); @@ -392,15 +483,23 @@ public function start(int|float|null $timeout = null): void 'server' => $this->identity, 'exception' => $e, 'message' => $e->getMessage(), +>>>>>>> v4.0-main ]); $this->dispatch('error', [$this, null, $e]); } catch (Throwable $e) { // Crash it +<<<<<<< HEAD + $this->configuration->getLogger()->error('[{identity}] {error}', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), +======= $this->configuration->getLogger()->error("[{scope}] {message}", [ 'scope' => self::SCOPE, 'server' => $this->identity, 'exception' => $e, 'message' => $e->getMessage(), +>>>>>>> v4.0-main ]); $this->disconnect(); throw $e; @@ -415,9 +514,14 @@ public function start(int|float|null $timeout = null): void public function stop(): void { $this->running = false; +<<<<<<< HEAD + $this->configuration->getLogger()->info('[{identity}] Server is stopped', [ + 'identity' => $this->identity, +======= $this->configuration->getLogger()->info("[{scope}] Server is stopped", [ 'scope' => self::SCOPE, 'server' => $this->identity, +>>>>>>> v4.0-main ]); } @@ -430,6 +534,49 @@ public function isRunning(): bool return $this->running; } + public function selectHandler(Connection $connection): void + { + $key = $connection->getIdentity(); + try { + // Read from connection + $message = $connection->pullMessage(); + $this->dispatch($message->getOpcode(), [$this, $connection, $message]); + } catch (MessageLevelInterface $e) { + // Error, but keep connection open + $this->configuration->getLogger()->error('[{identity}] {error} (message)', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), + ]); + $this->dispatch('error', [$this, $connection, $e]); + } catch (ConnectionLevelInterface $e) { + // Error, disconnect connection + if ($connection) { + $this->watcher->detach($connection->getIdentity()); + unset($this->connections[$key]); + $connection->disconnect(); + } + $this->configuration->getLogger()->error('[{identity}] {error} (connection)', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), + ]); + $this->dispatch('error', [$this, $connection, $e]); + } catch (CloseException $e) { + // Should close + if ($connection) { + $connection->close($e->getCloseStatus(), $e->getMessage()); + } + $this->configuration->getLogger()->info('[{identity}] {error} (close)', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), + 'status' => $e->getCloseStatus(), + ]); + $this->dispatch('error', [$this, $connection, $e]); + } + } + /* ---------- Connection management ---------------------------------------------------------------------------- */ @@ -439,9 +586,14 @@ public function isRunning(): bool */ public function shutdown(int $closeStatus = 1001): void { +<<<<<<< HEAD + $this->configuration->getLogger()->info('[{identity}] Shutting down', [ + 'identity' => $this->identity, +======= $this->configuration->getLogger()->info("[{scope}] Shutting down", [ 'scope' => self::SCOPE, 'server' => $this->identity, +>>>>>>> v4.0-main ]); if ($this->getConnectionCount() == 0) { $this->disconnect(); @@ -471,8 +623,15 @@ public function shutdown(int $closeStatus = 1001): void public function disconnect(): void { $this->running = false; +<<<<<<< HEAD + $this->watcher->detach($this->getIdentity()); + foreach ($this->connections as $connection) { + $connection->disconnect(); + $this->dispatch('disconnect', [$this, $connection]); +======= foreach ($this->getConnections() as $connection) { $this->disconnectConnection($connection); +>>>>>>> v4.0-main } $this->connections->reset(); if ($this->server) { @@ -480,20 +639,41 @@ public function disconnect(): void $this->runner->detach($this->identity); } $this->server = null; +<<<<<<< HEAD + $this->configuration->getLogger()->info('[{identity}] Server disconnected', [ + 'identity' => $this->identity, +======= $this->configuration->getLogger()->info("[{scope}] Server disconnected", [ 'scope' => self::SCOPE, 'server' => $this->identity, +>>>>>>> v4.0-main ]); } public function getStream(): SocketServer { return $this->server ?? $this->createSocketServer(); +<<<<<<< HEAD + } + + public function onSelect(): void + { + if ($this->server) { + $this->acceptHandler($this->server); + } +======= +>>>>>>> v4.0-main } /* ---------- Internal helper methods -------------------------------------------------------------------------- */ +<<<<<<< HEAD + /** + * Create socket server + * @throws ServerException Socket server could not be created + */ +======= protected function disconnectConnection(Connection $connection): void { $connection->disconnect(); @@ -502,10 +682,24 @@ protected function disconnectConnection(Connection $connection): void } // Create socket server +>>>>>>> v4.0-main protected function createSocketServer(): SocketServer { try { $uri = new Uri("{$this->scheme}://0.0.0.0:{$this->port}"); +<<<<<<< HEAD + $this->server = $this->streamFactory->createSocketServer($uri, $this->configuration->getContext()); + /** @throws StreamException */ + $this->watcher->attach($this->getIdentity(), $this->server, function (string $key, SocketServer $socket) { + $this->acceptHandler($socket); + }); + $this->allowConnections = true; + $this->configuration->getLogger()->info('[{identity}] Starting server on {uri}', [ + 'identity' => $this->identity, + 'uri' => $uri, + ]); + } catch (StreamException $e) { +======= $this->server = $server = $this->streamFactory->createSocketServer( $uri, $this->configuration->getContext() @@ -521,11 +715,40 @@ protected function createSocketServer(): SocketServer ]); return $server; } catch (Throwable $e) { +>>>>>>> v4.0-main $error = "Server failed to start: {$e->getMessage()}"; - throw new ServerException($error); + $this->configuration->getLogger()->error('[{identity}] {error}', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $error, + ]); + throw new ServerException($this, $error, $e); + } catch (Throwable $e) { + $error = "Server error: {$e->getMessage()}"; + $this->configuration->getLogger()->error('[{identity}] {error}', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $error, + ]); + throw $e; + } + if (is_null($this->server)) { + $error = "Server failed to start."; + $this->configuration->getLogger()->error("[{$this->identity}] {$error}"); + throw new ServerException($this, $error); } + return $this->server; } +<<<<<<< HEAD + // Accept connection on socket server + protected function acceptHandler(SocketServer $socket): void + { + $maxConnections = $this->configuration->getMaxConnections(); + if (!is_null($maxConnections) && $this->getConnectionCount() >= $maxConnections) { + $this->configuration->getLogger()->warning('[{identity}] Denied connection, reached max {maxConnections}', [ + 'identity' => $this->identity, +======= /** * Accept connection on socket server * @throws ConnectionFailureException @@ -538,20 +761,42 @@ protected function acceptSocket(SocketServer $socket): void 'scope' => self::SCOPE, 'server' => $this->identity, 'connections' => $this->getConnectionCount(), +>>>>>>> v4.0-main 'maxConnections' => $maxConnections, ]); return; } if (!$this->allowConnections) { +<<<<<<< HEAD + $this->configuration->getLogger()->warning("[server] Denied connection, shutting down"); + $this->configuration->getLogger()->warning('[{identity}] ', [ + 'identity' => $this->identity, +======= $this->configuration->getLogger()->warning("[{scope}] Denied connection, shutting down", [ 'scope' => self::SCOPE, 'server' => $this->identity, +>>>>>>> v4.0-main ]); return; } try { /** @var SocketStream $stream */ $stream = $socket->accept(); +<<<<<<< HEAD + $connection = new Connection( + $this, + $stream, + false, + true, + $this->isSsl(), + $this->httpFactory, + $this->configuration, + ); + $this->watcher->attach($connection); + $this->watcher->attach($connection->getIdentity(), $stream, function ($key, $stream) { + $this->selectHandler($key, $stream); + }); +======= $connection = $this->connections->create($stream, $this->isSsl()); $this->runner->attach($connection, function (Runner $runner, Connection $connection) { @@ -595,8 +840,9 @@ protected function acceptSocket(SocketServer $socket): void $this->dispatch('error', [$this, $connection, $e]); } }, $connection->getIdentity()); +>>>>>>> v4.0-main } catch (StreamException $e) { - throw new ConnectionFailureException("Server failed to accept: {$e->getMessage()}"); + throw new ConnectionFailureException(null, "Server failed to accept: {$e->getMessage()}", $e); } try { foreach ($this->middlewares as $middleware) { @@ -604,6 +850,13 @@ protected function acceptSocket(SocketServer $socket): void } /** @throws StreamException */ $request = $this->performHandshake($connection); +<<<<<<< HEAD + $this->connections[$connection->getIdentity()] = $connection; + $this->configuration->getLogger()->info('[{identity}] Accepted connection from {connection}', [ + 'identity' => $this->identity, + 'connection' => $connection->getIdentity(), + ]); +======= $this->connections->attach($connection); $this->configuration->getLogger()->info("[{scope}] Accepted connection from {connection}", [ 'scope' => self::SCOPE, @@ -611,6 +864,7 @@ protected function acceptSocket(SocketServer $socket): void 'connection' => $connection->getIdentity(), ]); +>>>>>>> v4.0-main $this->dispatch('handshake', [ $this, $connection, @@ -621,8 +875,28 @@ protected function acceptSocket(SocketServer $socket): void } catch (ExceptionInterface | StreamException $e) { $this->runner->detach($connection->getIdentity()); $connection->disconnect(); - throw new ConnectionFailureException("Server failed to accept: {$e->getMessage()}"); + throw new ConnectionFailureException($connection, "Server failed to accept: {$e->getMessage()}", $e); + } + } + + public function beforeStart(): void + { + if (empty($this->server)) { + $this->createSocketServer(); + } + } + + public function beforeWatch(): void + { + $this->detachUnconnected(); + } + + public function afterWatch(): void + { + foreach ($this->connections as $connection) { + $connection->tick(); } + $this->dispatch('tick', [$this]); } // Detach connections no longer available @@ -630,11 +904,18 @@ protected function detachUnconnected(): void { foreach ($this->getConnections() as $key => $connection) { if (!$connection->isConnected()) { +<<<<<<< HEAD + $this->watcher->detach($key); + unset($this->connections[$key]); + $this->configuration->getLogger()->info('[{identity}] Disconnected {key}.', [ + 'identity' => $this->identity, +======= $this->runner->detach($key); $this->connections->detach($key); $this->configuration->getLogger()->info("[{scope}] Disconnected {connection}", [ 'scope' => self::SCOPE, 'server' => $this->identity, +>>>>>>> v4.0-main 'connection' => $connection->getIdentity(), ]); $this->dispatch('disconnect', [$this, $connection]); @@ -645,7 +926,11 @@ protected function detachUnconnected(): void // Perform upgrade handshake on new connections. protected function performHandshake(Connection $connection): ServerRequestInterface { +<<<<<<< HEAD + $response = $this->httpFactory->createResponse(101); +======= $response = $this->httpFactory->createResponse(101, 'Switching Protocols'); +>>>>>>> v4.0-main $exception = null; // Read handshake request @@ -656,53 +941,89 @@ protected function performHandshake(Connection $connection): ServerRequestInterf try { if ($request->getMethod() != 'GET') { throw new HandshakeException( +<<<<<<< HEAD + $connection, + $response->withStatus(405), + "Handshake request with invalid method: '{$request->getMethod()}'", +======= 'Handshake request with invalid method: {method}', response: $response->withStatus(405), method: $request->getMethod(), +>>>>>>> v4.0-main ); } $connectionHeader = trim($request->getHeaderLine('Connection')); if (!str_contains(strtolower($connectionHeader), 'upgrade')) { throw new HandshakeException( +<<<<<<< HEAD + $connection, + $response->withStatus(426), + "Handshake request with invalid Connection header: '{$connectionHeader}'", +======= 'Handshake request with invalid {headerName} header: {headerValue}', response: $response->withStatus(426), headerName: 'Connection', headerValue: $connectionHeader, +>>>>>>> v4.0-main ); } $upgradeHeader = trim($request->getHeaderLine('Upgrade')); if (strtolower($upgradeHeader) != 'websocket') { throw new HandshakeException( +<<<<<<< HEAD + $connection, + $response->withStatus(426), + "Handshake request with invalid Upgrade header: '{$upgradeHeader}'", +======= 'Handshake request with invalid {headerName} header: {headerValue}', response: $response->withStatus(426), headerName: 'Upgrade', headerValue: $upgradeHeader, +>>>>>>> v4.0-main ); } $versionHeader = trim($request->getHeaderLine('Sec-WebSocket-Version')); if ($versionHeader != '13') { throw new HandshakeException( +<<<<<<< HEAD + $connection, + $response->withStatus(426)->withHeader('Sec-WebSocket-Version', '13'), + "Handshake request with invalid Sec-WebSocket-Version header: '{$versionHeader}'", +======= 'Handshake request with invalid {headerName} header: {headerValue}', response: $response->withStatus(426)->withHeader('Sec-WebSocket-Version', '13'), headerName: 'Sec-WebSocket-Version', headerValue: $versionHeader, +>>>>>>> v4.0-main ); } $keyHeader = trim($request->getHeaderLine('Sec-WebSocket-Key')); if (empty($keyHeader)) { throw new HandshakeException( +<<<<<<< HEAD + $connection, + $response->withStatus(426), + "Handshake request with invalid Sec-WebSocket-Key header: '{$keyHeader}'", +======= 'Handshake request with invalid {headerName} header: {headerValue}', response: $response->withStatus(426), headerName: 'Sec-WebSocket-Key', headerValue: $keyHeader, +>>>>>>> v4.0-main ); } if (strlen(base64_decode($keyHeader)) != 16) { throw new HandshakeException( +<<<<<<< HEAD + $connection, + $response->withStatus(426), + "Handshake request with invalid Sec-WebSocket-Key header: '{$keyHeader}'", +======= 'Handshake request with invalid {headerName} header: {headerValue}', response: $response->withStatus(426), headerName: 'Sec-WebSocket-Key', headerValue: $keyHeader, +>>>>>>> v4.0-main ); } @@ -712,12 +1033,19 @@ protected function performHandshake(Connection $connection): ServerRequestInterf ->withHeader('Connection', 'Upgrade') ->withHeader('Sec-WebSocket-Accept', $responseKey); } catch (HandshakeException $e) { +<<<<<<< HEAD + $this->configuration->getLogger()->warning('[{identity}] {error} (handshake)', [ + 'identity' => $this->identity, + 'exception' => $e, + 'error' => $e->getMessage(), +======= $this->configuration->getLogger()->warning("[{scope}] {message}", [ 'scope' => self::SCOPE, 'server' => $this->identity, 'connection' => $connection->getIdentity(), 'exception' => $e, 'message' => $e->getMessage(), +>>>>>>> v4.0-main ]); $response = $e->getResponse(); $exception = $e; @@ -728,9 +1056,15 @@ protected function performHandshake(Connection $connection): ServerRequestInterf $response = $connection->pushHttp($response); if ($response->getStatusCode() != 101) { $exception = new HandshakeException( +<<<<<<< HEAD + $connection, + $response, + "Invalid status code {$response->getStatusCode()}", +======= 'Invalid status code {statusCode}', response: $response, statusCode: $response->getStatusCode(), +>>>>>>> v4.0-main ); } @@ -738,10 +1072,15 @@ protected function performHandshake(Connection $connection): ServerRequestInterf throw $exception; } +<<<<<<< HEAD + $this->configuration->getLogger()->debug('[{identity}] Handshake on {path}', [ + 'identity' => $this->identity, +======= $this->configuration->getLogger()->debug("[{scope}] Handshake on {path}", [ 'scope' => self::SCOPE, 'server' => $this->identity, 'connection' => $connection->getIdentity(), +>>>>>>> v4.0-main 'path' => $request->getUri()->getPath(), ]); diff --git a/tests/mock/EchoLog.php b/tests/mock/EchoLog.php deleted file mode 100644 index c52fd94..0000000 --- a/tests/mock/EchoLog.php +++ /dev/null @@ -1,25 +0,0 @@ -setUpStack(); + } + + public function tearDown(): void + { + $this->tearDownStack(); + } + + public function testClientException(): void + { + $client = new Client('ws://localhost:8000'); + + try { + throw new ClientException(); + } catch (ClientException $e) { + ; + } + $this->assertInstanceOf(ClientException::class, $e); + $this->assertInstanceOf(AbstractHandlerException::class, $e); + $this->assertInstanceOf(AbstractException::class, $e); + $this->assertInstanceOf(HandlerLevelInterface::class, $e); + $this->assertInstanceOf(ExceptionInterface::class, $e); + $this->assertEquals('Unspecified handler error', $e->getMessage()); + $this->assertNull($e->getHandler()); + + try { + throw new ClientException($client, 'Test message'); + } catch (ClientException $e) { + ; + } + $this->assertEquals('Test message', $e->getMessage()); + $this->assertSame($client, $e->getHandler()); + } + + public function testServerException(): void + { + $server = new Server(); + + try { + throw new ServerException(); + } catch (ServerException $e) { + ; + } + $this->assertInstanceOf(ServerException::class, $e); + $this->assertInstanceOf(AbstractHandlerException::class, $e); + $this->assertInstanceOf(AbstractException::class, $e); + $this->assertInstanceOf(HandlerLevelInterface::class, $e); + $this->assertInstanceOf(ExceptionInterface::class, $e); + $this->assertEquals('Unspecified handler error', $e->getMessage()); + $this->assertNull($e->getHandler()); + + try { + throw new ServerException($server, 'Test message'); + } catch (ServerException $e) { + ; + } + $this->assertEquals('Test message', $e->getMessage()); + $this->assertSame($server, $e->getHandler()); + } + + public function testBadUriException(): void + { + $client = new Client('ws://localhost:8000'); + + try { + throw new BadUriException($client); + } catch (BadUriException $e) { + ; + } + $this->assertInstanceOf(BadUriException::class, $e); + $this->assertInstanceOf(AbstractHandlerException::class, $e); + $this->assertInstanceOf(AbstractException::class, $e); + $this->assertInstanceOf(HandlerLevelInterface::class, $e); + $this->assertInstanceOf(ExceptionInterface::class, $e); + $this->assertEquals('Bad URI', $e->getMessage()); + $this->assertSame($client, $e->getHandler()); + + try { + throw new BadUriException($client, 'Test message'); + } catch (BadUriException $e) { + ; + } + $this->assertEquals('Test message', $e->getMessage()); + } + + public function testConnectionClosedException(): void + { + $this->expectWsClientCreate(); + $client = new Client('ws://localhost:8000/my/mock/path', streamFactory: new StreamFactory()); + $this->expectWsClientConnect(); + $this->expectWsClientPerformHandshake(); + $client->connect(); + $connection = $client->getConnection(); + + try { + throw new ConnectionClosedException(); + } catch (ConnectionClosedException $e) { + ; + } + $this->assertInstanceOf(ConnectionClosedException::class, $e); + $this->assertInstanceOf(AbstractConnectionException::class, $e); + $this->assertInstanceOf(AbstractException::class, $e); + $this->assertInstanceOf(ConnectionLevelInterface::class, $e); + $this->assertInstanceOf(ExceptionInterface::class, $e); + $this->assertEquals('Connection has unexpectedly closed', $e->getMessage()); + $this->assertNull($e->getConnection()); + $this->assertNull($e->getHandler()); + + try { + throw new ConnectionClosedException($connection, 'Test message'); + } catch (ConnectionClosedException $e) { + ; + } + $this->assertEquals('Test message', $e->getMessage()); + $this->assertSame($connection, $e->getConnection()); + $this->assertSame($client, $e->getHandler()); + } + + public function testConnectionFailureException(): void + { + $this->expectWsClientCreate(); + $client = new Client('ws://localhost:8000/my/mock/path', streamFactory: new StreamFactory()); + $this->expectWsClientConnect(); + $this->expectWsClientPerformHandshake(); + $client->connect(); + /** @var Connection $connection */ + $connection = $client->getConnection(); + + try { + throw new ConnectionFailureException(); + } catch (ConnectionFailureException $e) { + ; + } + $this->assertInstanceOf(ConnectionFailureException::class, $e); + $this->assertInstanceOf(AbstractConnectionException::class, $e); + $this->assertInstanceOf(AbstractException::class, $e); + $this->assertInstanceOf(ConnectionLevelInterface::class, $e); + $this->assertInstanceOf(ExceptionInterface::class, $e); + $this->assertEquals('Connection error', $e->getMessage()); + $this->assertNull($e->getConnection()); + $this->assertNull($e->getHandler()); + + try { + throw new ConnectionFailureException($connection, 'Test message'); + } catch (ConnectionFailureException $e) { + ; + } + $this->assertEquals('Test message', $e->getMessage()); + $this->assertSame($connection, $e->getConnection()); + $this->assertSame($client, $e->getHandler()); + } + + public function testHandshakeException(): void + { + $this->expectWsClientCreate(); + $client = new Client('ws://localhost:8000/my/mock/path', streamFactory: new StreamFactory()); + $this->expectWsClientConnect(); + $this->expectWsClientPerformHandshake(); + $client->connect(); + /** @var Connection $connection */ + $connection = $client->getConnection(); + $response = new Response(); + + try { + throw new HandshakeException($connection, $response); + } catch (HandshakeException $e) { + ; + } + $this->assertInstanceOf(HandshakeException::class, $e); + $this->assertInstanceOf(AbstractConnectionException::class, $e); + $this->assertInstanceOf(AbstractException::class, $e); + $this->assertInstanceOf(ConnectionLevelInterface::class, $e); + $this->assertInstanceOf(ExceptionInterface::class, $e); + $this->assertEquals('Handshake failed', $e->getMessage()); + $this->assertSame($connection, $e->getConnection()); + $this->assertSame($client, $e->getHandler()); + $this->assertSame($response, $e->getResponse()); + + try { + throw new HandshakeException($connection, $response, 'Test message'); + } catch (HandshakeException $e) { + ; + } + $this->assertEquals('Test message', $e->getMessage()); + } + + public function testBadOpcodeException(): void + { + try { + throw new BadOpcodeException(); + } catch (BadOpcodeException $e) { + ; + } + $this->assertInstanceOf(BadOpcodeException::class, $e); + $this->assertInstanceOf(AbstractMessageException::class, $e); + $this->assertInstanceOf(AbstractException::class, $e); + $this->assertInstanceOf(MessageLevelInterface::class, $e); + $this->assertInstanceOf(ExceptionInterface::class, $e); + $this->assertEquals('Bad Opcode', $e->getMessage()); + + try { + throw new BadOpcodeException('Test message'); + } catch (BadOpcodeException $e) { + ; + } + $this->assertEquals('Test message', $e->getMessage()); + } + + public function testConnectionTimeoutException(): void + { + try { + throw new ConnectionTimeoutException(); + } catch (ConnectionTimeoutException $e) { + ; + } + $this->assertInstanceOf(ConnectionTimeoutException::class, $e); + $this->assertInstanceOf(AbstractMessageException::class, $e); + $this->assertInstanceOf(AbstractException::class, $e); + $this->assertInstanceOf(MessageLevelInterface::class, $e); + $this->assertInstanceOf(ExceptionInterface::class, $e); + $this->assertEquals('Connection operation timeout', $e->getMessage()); + + try { + throw new ConnectionTimeoutException('Test message'); + } catch (ConnectionTimeoutException $e) { + ; + } + $this->assertEquals('Test message', $e->getMessage()); + } + + public function testMessageEncodingException(): void + { + try { + throw new MessageEncodingException(); + } catch (MessageEncodingException $e) { + ; + } + $this->assertInstanceOf(MessageEncodingException::class, $e); + $this->assertInstanceOf(AbstractMessageException::class, $e); + $this->assertInstanceOf(AbstractException::class, $e); + $this->assertInstanceOf(MessageLevelInterface::class, $e); + $this->assertInstanceOf(ExceptionInterface::class, $e); + $this->assertEquals('Message encoding error', $e->getMessage()); + + try { + throw new MessageEncodingException('Test message'); + } catch (MessageEncodingException $e) { + ; + } + $this->assertEquals('Test message', $e->getMessage()); + } + + public function testCloseException(): void + { + try { + throw new CloseException(); + } catch (CloseException $e) { + ; + } + $this->assertInstanceOf(CloseException::class, $e); + $this->assertInstanceOf(AbstractException::class, $e); + $this->assertInstanceOf(ControlInterface::class, $e); + $this->assertInstanceOf(ExceptionInterface::class, $e); + $this->assertEquals(1000, $e->getCloseStatus()); + $this->assertEquals('Closing connection', $e->getMessage()); + + try { + throw new CloseException(1200, 'Test message'); + } catch (CloseException $e) { + ; + } + $this->assertEquals(1200, $e->getCloseStatus()); + $this->assertEquals('Test message', $e->getMessage()); + } + + public function testReconnectException(): void + { + $uri = new Uri('ws://localhost:9000'); + + try { + throw new ReconnectException(); + } catch (ReconnectException $e) { + ; + } + $this->assertInstanceOf(ReconnectException::class, $e); + $this->assertInstanceOf(AbstractException::class, $e); + $this->assertInstanceOf(ControlInterface::class, $e); + $this->assertInstanceOf(ExceptionInterface::class, $e); + $this->assertNull($e->getUri()); + $this->assertEquals('Reconnect connection', $e->getMessage()); + + try { + throw new ReconnectException($uri, 'Test message'); + } catch (ReconnectException $e) { + ; + } + $this->assertEquals($uri, $e->getUri()); + $this->assertEquals('Test message', $e->getMessage()); + } +}