diff --git a/constants.php b/constants.php index 5130f729519d9fe0320208c5889a8807e81ab1f6..8478d6e0d00034ecada9e22ec4b80127e0af0940 100644 --- a/constants.php +++ b/constants.php @@ -5,6 +5,7 @@ declare(strict_types=1); define('DM_APP_ROOT', __DIR__.'/app'); define('DM_BATCH_PROMISE_TIMEOUT', 0.3); define('DM_GRAPHQL_PROMISE_TIMEOUT', 0.2); +define('DM_POOL_CONNECTION_TIMEOUT', 0.1); define('DM_PUBLIC_ROOT', __DIR__.'/public'); define('DM_RESONANCE_ROOT', __DIR__.'/src'); define('DM_ROOT', __DIR__); diff --git a/docs/pages/docs/features/ai/index.md b/docs/pages/docs/features/ai/index.md index 4413ef18602e3fa244958042d655bcbbb9e45ebc..747e70a4ea36f5da626a6fcc89fe66176daa05e8 100644 --- a/docs/pages/docs/features/ai/index.md +++ b/docs/pages/docs/features/ai/index.md @@ -3,9 +3,11 @@ collections: - documents layout: dm:document parent: docs/features/index -title: AI +title: Artificial Intelligence description: > Use integration features to serve or use AI models. --- +# Artificial Intelligence + {{docs/features/ai/*/index}} diff --git a/docs/pages/docs/features/ai/llama-cpp/index.md b/docs/pages/docs/features/ai/llama-cpp/index.md index faa10672922c9a73b8be4af5ce235fd8fab03393..49ecacbb27ea9d33e974ad3a7c0e476ce2142938 100644 --- a/docs/pages/docs/features/ai/llama-cpp/index.md +++ b/docs/pages/docs/features/ai/llama-cpp/index.md @@ -20,7 +20,7 @@ You can use Resonance to connect with it and process LLM responses. # Usage -You can also check the tutorial: {{tutorials/connect-to-llama-cpp/index}} +You can also check the tutorial: {{tutorials/how-to-serve-llm-completions/index}} ## Configuration @@ -71,3 +71,30 @@ class LlamaCppGenerate } } ``` + +## Stopping Completion Generator + +:::danger +Using just the `break` keyword does not stop the completion request. You need +to use `$completion->send(...)`. + +See also: [Generator::send(...)](https://www.php.net/manual/en/generator.send.php) +::: + +For example, stops after generating 10 tokens: + +```php +use Distantmagic\Resonance\LlamaCppCompletionCommand; + +$i = 0; + +foreach ($completion as $token) { + if ($i > 9) { + $completion->send(LlamaCppCompletionCommand::Stop); + } else { + // do something + + $i += 1; + } +} +``` diff --git a/docs/pages/docs/features/configuration/index.md b/docs/pages/docs/features/configuration/index.md index a3b7d1108c22adb67fbbffa6df13596a720638c7..3f581383e34eb2adf642cefcc84b98a37ea2a407 100644 --- a/docs/pages/docs/features/configuration/index.md +++ b/docs/pages/docs/features/configuration/index.md @@ -37,6 +37,7 @@ define('DM_ROOT', __DIR__); // Coroutine timeouts define('DM_BATCH_PROMISE_TIMEOUT', 0.3); define('DM_GRAPHQL_PROMISE_TIMEOUT', 0.2); +define('DM_POOL_CONNECTION_TIMEOUT', 0.1); ``` ```json file:composer.json diff --git a/docs/pages/docs/features/websockets/protocols.md b/docs/pages/docs/features/websockets/protocols.md index ef966e66c9f8f5b7972d50c8099e8728792e655c..4af04e3689b22c14be67e428fed45b9a4e6061e3 100644 --- a/docs/pages/docs/features/websockets/protocols.md +++ b/docs/pages/docs/features/websockets/protocols.md @@ -25,8 +25,32 @@ digraph { ### Establishing the Connection +Besides connecting to the server and choosing a protocol, you also have to +add {{docs/features/security/csrf-protection/index}} token. Since we cannot +use `POST` to establish connection, we need to add it as a `GET` variable. + ```typescript -const webSocket = new WebSocket('wss://localhost:9501', ['dm-rpc']); +const serverUrl = new URL('wss://localhost:9501'); + +serverUrl.searchParams.append('csrf', /* obtain CSRF token */); + +const webSocket = new WebSocket(serverUrl, ['dm-rpc']); +``` + +For example, if you are using {{docs/features/templating/twig/index}}, you can +put CSRF token into a meta tag: + +```twig +<meta name="csrf-token" content="{{ csrf_token(request, response) }}"> +``` + +Then, in JavaScript: + +```typescript +serverUrl.searchParams.append( + 'csrf', + document.querySelector("meta[name=csrf-token]").attributes.content.value, +); ``` ### Writing RPC Responders @@ -39,6 +63,7 @@ namespace App\WebSocketRPCResponder; use App\RPCMethod; use Distantmagic\Resonance\Attribute\RespondsToWebSocketRPC; use Distantmagic\Resonance\Attribute\Singleton; +use Distantmagic\Resonance\Feature; use Distantmagic\Resonance\RPCRequest; use Distantmagic\Resonance\RPCResponse; use Distantmagic\Resonance\SingletonCollection; @@ -47,7 +72,10 @@ use Distantmagic\Resonance\WebSocketConnection; use Distantmagic\Resonance\WebSocketRPCResponder; #[RespondsToWebSocketRPC(RPCMethod::Echo)] -#[Singleton(collection: SingletonCollection::WebSocketRPCResponder)] +#[Singleton( + collection: SingletonCollection::WebSocketRPCResponder, + wantsFeature: Feature::WebSocket, +)] final readonly class EchoResponder extends WebSocketRPCResponder { protected function onRequest( @@ -62,3 +90,56 @@ final readonly class EchoResponder extends WebSocketRPCResponder } } ``` + +### RPC Connection Controller (Optional) + +In case you want not only respond to RPC messages, but also be able to push +notifications to the client at any moment, you can implement +`Distantmagic\Resonance\WebSocketRPCConnectionControllerInterface` and +register in in the {{docs/features/dependency-injection/index}} container. + +For example: + +```php file:app/WebSocketRPCConnectionController.php +<?php + +namespace App; + +use Distantmagic\Resonance\Attribute\Singleton; +use Distantmagic\Resonance\Feature; +use Distantmagic\Resonance\RPCNotification; +use Distantmagic\Resonance\WebSocketAuthResolution; +use Distantmagic\Resonance\WebSocketConnection; +use Distantmagic\Resonance\WebSocketRPCConnectionControllerInterface; + +#[Singleton( + provides: WebSocketRPCConnectionControllerInterface::class, + wantsFeature: Feature::WebSocket, +)] +readonly class WebSocketRPCConnectionController implements WebSocketRPCConnectionControllerInterface +{ + public function onClose( + WebSocketAuthResolution $webSocketAuthResolution, + WebSocketConnection $webSocketConnection, + ): void { + // called when connection is closed + } + + public function onOpen( + WebSocketAuthResolution $webSocketAuthResolution, + WebSocketConnection $webSocketConnection, + ): void + { + if ($webSocketConnection->status === WebSocketConnectionStatus::Closed) { + // connection is closed + } + + $webSocketConnection->push(new RPCNotification( + RPCMethod::YourMethod, + [ + // your payload + ] + )); + } +} +``` diff --git a/docs/pages/index.md b/docs/pages/index.md index 804ee2d7a4226cbdbcffff05b4c04184d264322e..837817b6e02286b82dfeef3ae45ef90a4d4b9d44 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -17,7 +17,7 @@ description: > <div class="homepage__content"> <hgroup class="homepage__title"> <h1>Resonance</h1> - <h2>PHP SaaS Framework</h2> + <h2>SaaS Framework That Solves Real-Life Issues</h2> <p> Designed from the ground up to facilitate interoperability and messaging between services in your infrastructure and @@ -37,6 +37,47 @@ description: > </a> </hgroup> <ul class="homepage__examples"> + <li class="formatted-content homepage__example"> + <h2 class="homepage__example__title"> + Artificial Intelligence + </h2> + <div class="homepage__example__description"> + <p> + Integrate your application with self-hosted open-source + LLMs. + </p> + <p> + Use your own Machine Learning models in production. + </p> + <a + class="homepage__cta homepage__cta--example" + href="/docs/features/ai/" + > + Learn More + </a> + </div> + <pre class="homepage__example__code fenced-code"><code + class="language-php" + data-controller="hljs" + data-hljs-language-value="php" + >class LlamaCppGenerate +{ + public function __construct(protected LlamaCppClient $llamaCppClient) + { + } + + public function doSomething(): void + { + $request = new LlamaCppCompletionRequest('How to make a cat happy?'); + + $completion = $this->llamaCppClient->generateCompletion($request); + + foreach ($completion as $token) { + // ... + } + } +}</code></pre> + </li> <li class="formatted-content homepage__example"> <h2 class="homepage__example__title"> Asynchronous Where it Matters @@ -141,8 +182,7 @@ readonly class Homepage implements HttpResponderInterface class="language-php" data-controller="hljs" data-hljs-language-value="php" - > -#[ListensTo(HttpServerStarted::class)] + >#[ListensTo(HttpServerStarted::class)] #[Singleton(collection: SingletonCollection::EventListener)] final readonly class InitializeErrorReporting extends EventListener { diff --git a/docs/pages/tutorials/how-to-create-llm-chat-with-websocket-and-llama-cpp/index.md b/docs/pages/tutorials/how-to-create-llm-chat-with-websocket-and-llama-cpp/index.md new file mode 100644 index 0000000000000000000000000000000000000000..8ea6c3fd4aec40a749fc0e1937d5783031101afc --- /dev/null +++ b/docs/pages/tutorials/how-to-create-llm-chat-with-websocket-and-llama-cpp/index.md @@ -0,0 +1,126 @@ +--- +collections: + - tutorials +draft: true +layout: dm:tutorial +parent: tutorials/index +title: How to Create LLM Chat with WebSocket and llama.cpp? +description: > + Learn how to setup a WebSocket server and create basic chat with an open + source LLM. +--- + +## Preparations + +Before starting this tutorial, it might be useful for you to familiarize with +the other tutorials and documentation pages first: + +* {{tutorials/how-to-serve-llm-completions/index}} - to setup + [llama.cpp](https://github.com/ggerganov/llama.cpp) server and configure + Resonance to use it +* {{docs/features/websockets/index}} - to learn how WebSocket featres are + implemented in Resonance + +In this tutorial, we will also use [Stimulus](https://stimulus.hotwired.dev/) +for the front-end code. + +Once you have both Resonance and +[llama.cpp](https://github.com/ggerganov/llama.cpp) runing, we can continue. + +## Http Responder + +```php file:app/RPCMethod.php +<?php + +namespace App; + +use Distantmagic\Resonance\EnumValuesTrait; +use Distantmagic\Resonance\NameableEnumTrait; +use Distantmagic\Resonance\RPCMethodInterface; + +enum RPCMethod: string implements RPCMethodInterface +{ + use EnumValuesTrait; + use NameableEnumTrait; + + case LlmChatReady = 'llm_chat_ready'; +} +``` + +```php file:app/HttpResponder/LlmChat.php +<?php + +declare(strict_types=1); + +namespace App\HttpResponder\Webapp; + +use App\HttpRouteSymbol; +use Distantmagic\Resonance\Attribute\Can; +use Distantmagic\Resonance\Attribute\RespondsToHttp; +use Distantmagic\Resonance\Attribute\Singleton; +use Distantmagic\Resonance\HttpInterceptableInterface; +use Distantmagic\Resonance\HttpResponder; +use Distantmagic\Resonance\RequestMethod; +use Distantmagic\Resonance\SingletonCollection; +use Distantmagic\Resonance\SiteAction; +use Distantmagic\Resonance\TwigTemplate; +use Swoole\Http\Request; +use Swoole\Http\Response; + +#[Can(SiteAction::StartWebSocketRPCConnection)] +#[RespondsToHttp( + method: RequestMethod::GET, + pattern: '/chat', + routeSymbol: HttpRouteSymbol::LlmChat, +)] +#[Singleton(collection: SingletonCollection::HttpResponder)] +final readonly class LlmChat extends HttpResponder +{ + public function respond(Request $request, Response $response): HttpInterceptableInterface + { + return new TwigTemplate('llmchat.twig'); + } +} +``` + +```twig file:app/views/llamachat.twig +turbo/llmchat/index.twig +``` + +## Front-end + +We will use {{docs/features/asset-bundling-esbuild/index}} to bundle front-end +TypeScript code. + +```shell +$ ./node_modules/.bin/esbuild \ + --bundle \ + --define:global=globalThis \ + --entry-names="./[name]_$(BUILD_ID)" \ + --format=esm \ + --log-limit=0 \ + --metafile=esbuild-meta-app.json \ + --minify \ + --outdir=./$(BUILD_TARGET_DIRECTORY) \ + --platform=browser \ + --sourcemap \ + --target=es2022,safari16 \ + --tree-shaking=true \ + --tsconfig=tsconfig.json \ + resources/ts/controller_llmchat.ts \ +; +``` + +```typescript file:resources/ts/controller_llmchat.ts +import { Controller } from "@hotwired/stimulus"; + +import { stimulus } from "../stimulus"; + +@stimulus("llmchat") +export class controller_llmchat extends Controller<HTMLElement> { + public static targets = ["textarea", "textareaGrow"]; + + private declare readonly textareaGrowTarget: HTMLElement; + private declare readonly textareaTarget: HTMLTextAreaElement; +} +``` diff --git a/docs/pages/tutorials/connect-to-llama-cpp/index.md b/docs/pages/tutorials/how-to-serve-llm-completions/index.md similarity index 98% rename from docs/pages/tutorials/connect-to-llama-cpp/index.md rename to docs/pages/tutorials/how-to-serve-llm-completions/index.md index 2604136537d7217e76eedb18cbc44b5453503a37..166820e4b10c8205f95e65e807bbbf59179a0eb8 100644 --- a/docs/pages/tutorials/connect-to-llama-cpp/index.md +++ b/docs/pages/tutorials/how-to-serve-llm-completions/index.md @@ -3,7 +3,7 @@ collections: - tutorials layout: dm:tutorial parent: tutorials/index -title: How to Serve LLM Completions (With llama.cpp) +title: How to Serve LLM Completions (With llama.cpp)? description: > How to connect with llama.cpp and issue parallel requests for LLM completions and embeddings with Resonance. diff --git a/docs/pages/tutorials/session-based-authentication/index.md b/docs/pages/tutorials/session-based-authentication/index.md index cf61a5cca292bed622701cee9787f8a1fdaf2faf..0559023dedaa3692485c7a42cc448aa796482729 100644 --- a/docs/pages/tutorials/session-based-authentication/index.md +++ b/docs/pages/tutorials/session-based-authentication/index.md @@ -153,11 +153,9 @@ declare(strict_types=1); namespace App; use Distantmagic\Resonance\Attribute\Singleton; -use Distantmagic\Resonance\DatabaseConnectionPoolRepository; use Distantmagic\Resonance\DoctrineEntityManagerRepository; use Distantmagic\Resonance\UserInterface; use Distantmagic\Resonance\UserRepositoryInterface; -use LogicException; use Swoole\Http\Request; #[Singleton(provides: UserRepositoryInterface::class)] @@ -167,13 +165,19 @@ readonly class UserRepository implements UserRepositoryInterface private DoctrineEntityManagerRepository $doctrineEntityManagerRepository, ) {} - public function findUserById(Request $request, int|string $userId): ?UserInterface + public function findUserById(int|string $userId): ?UserInterface { return $this ->doctrineEntityManagerRepository - ->getEntityManager($request) - ->getRepository(User::class) - ->find($userId) + ->withRepository(User::class, function ( + EntityManagerInterface $entityManager, + EntityRepository $entityRepository, + ) use ($userId) { + /** + * @var null|UserInterface + */ + return $entityRepository->find($userId); + }) ; } } diff --git a/resources/css/docs-page-homepage.css b/resources/css/docs-page-homepage.css index 99ad27395049fe94794fe0cc1ead2ae283ca49d5..4422f7dc1e9539baf031e5e66545e55bed0f839f 100644 --- a/resources/css/docs-page-homepage.css +++ b/resources/css/docs-page-homepage.css @@ -108,9 +108,12 @@ h2.homepage__example__title { background-repeat: no-repeat; background-size: cover; } - @media screen and (min-width: 1024px) { + @media screen and (min-width: 1024px) and (max-width: 1649px) { padding: 160px 0; } + @media screen and (min-width: 1650px) { + padding: 210px 0; + } } .homepage__title h1 { @@ -126,7 +129,7 @@ h2.homepage__example__title { } .homepage__title h2 { - margin: 0px 0 100px 0; + margin: 0px 0 40px 0; text-align: center; @media screen and (max-width: 1023px) { diff --git a/src/Command/Watch.php b/src/Command/Watch.php index fce35a4bab429f3da03d53f00fe6a60cb4822e84..e7a941a6cbf8ea20c057a69c4ed12b9f69f4be0b 100644 --- a/src/Command/Watch.php +++ b/src/Command/Watch.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Distantmagic\Resonance\Command; +use Distantmagic\Resonance\ApplicationConfiguration; use Distantmagic\Resonance\Attribute\ConsoleCommand; use Distantmagic\Resonance\Command; use Distantmagic\Resonance\InotifyIterator; @@ -20,9 +21,12 @@ use Symfony\Component\Console\Output\OutputInterface; )] final class Watch extends Command { + private const THROTTLE_TIME_MS = 10; + private ?Process $process = null; public function __construct( + private ApplicationConfiguration $applicationConfiguration, private LoggerInterface $logger, ) { parent::__construct(); @@ -47,7 +51,7 @@ final class Watch extends Command $directories = [ DM_APP_ROOT, DM_APP_ROOT.'/../config.ini', - DM_PUBLIC_ROOT, + $this->applicationConfiguration->esbuildMetafile, DM_RESONANCE_ROOT, ]; diff --git a/src/DatabaseConnectionPoolRepository.php b/src/DatabaseConnectionPoolRepository.php index 1d678ece5912af1763c16342d11b4b1901b6fb66..c4ee1a367a96e148908a73468b457ee8152b311e 100644 --- a/src/DatabaseConnectionPoolRepository.php +++ b/src/DatabaseConnectionPoolRepository.php @@ -7,6 +7,7 @@ namespace Distantmagic\Resonance; use Ds\Map; use OutOfBoundsException; use PDO; +use RuntimeException; use Swoole\Database\PDOPool; use Swoole\Database\PDOProxy; @@ -33,9 +34,15 @@ readonly class DatabaseConnectionPoolRepository } /** - * @var PDO|PDOProxy + * @var false|PDO|PDOProxy */ - return $this->databaseConnectionPool->get($name)->get(); + $pdo = $this->databaseConnectionPool->get($name)->get(DM_POOL_CONNECTION_TIMEOUT); + + if (!$pdo) { + throw new RuntimeException('Database connection timed out'); + } + + return $pdo; } public function putConnection(string $name, PDO|PDOProxy $pdo): void diff --git a/src/DoctrineEntityManagerRepository.php b/src/DoctrineEntityManagerRepository.php index eefbd356edd1e022d8e6151969b05fefb0e60158..759ee6c03bd0d38a98da72a286f5678ae87f9234 100644 --- a/src/DoctrineEntityManagerRepository.php +++ b/src/DoctrineEntityManagerRepository.php @@ -40,6 +40,11 @@ readonly class DoctrineEntityManagerRepository ); } + public function createContextKey(string $name): string + { + return sprintf('%s.%s', __CLASS__, $name); + } + public function getEntityManager(Request $request, string $name = 'default'): EntityManagerInterface { if (!$this->entityManagers->offsetExists($request)) { @@ -52,9 +57,23 @@ readonly class DoctrineEntityManagerRepository return $entityManagers->get($name); } + /** + * @var null|Context $context + */ + $context = Coroutine::getContext(); + $contextKey = $this->createContextKey($name); + + if ($context && isset($context[$contextKey]) && $context[$contextKey] instanceof EntityManagerWeakReference) { + return $context[$contextKey]->getEntityManager(); + } + $conn = $this->doctrineConnectionRepository->getConnection($request, $name); $entityManager = new EntityManager($conn, $this->configuration); + if ($context) { + $context[$contextKey] = new EntityManagerWeakReference($entityManager); + } + $entityManagers->put($name, $entityManager); return $entityManager; @@ -73,8 +92,7 @@ readonly class DoctrineEntityManagerRepository * @var null|Context $context */ $context = Coroutine::getContext(); - - $contextKey = sprintf('%s.entityManager.%s', __METHOD__, $name); + $contextKey = $this->createContextKey($name); if ($context && isset($context[$contextKey])) { $entityManagerWeakReference = $context[$contextKey]; diff --git a/src/InputValidator/RPCMessageValidator.php b/src/InputValidator/RPCMessageValidator.php index 3acf1f71c576a53ce51fd848e9e4d14fed086920..7716c6870de02f7324c9a4aa49b081e3d521280e 100644 --- a/src/InputValidator/RPCMessageValidator.php +++ b/src/InputValidator/RPCMessageValidator.php @@ -11,6 +11,7 @@ use Distantmagic\Resonance\InputValidator; use Distantmagic\Resonance\JsonSchema; use Distantmagic\Resonance\JsonSchemaValidator; use Distantmagic\Resonance\RPCMethodValidatorInterface; +use stdClass; /** * @extends InputValidator<RPCMessage, array{ @@ -42,17 +43,16 @@ readonly class RPCMessageValidator extends InputValidator { return new JsonSchema([ 'type' => 'array', - 'items' => [ + 'items' => false, + 'prefixItems' => [ [ 'type' => 'string', - 'enum' => $this->rpcMethodValidator->names(), + 'enum' => $this->rpcMethodValidator->values(), ], + new stdClass(), [ - ], - [ - 'type' => 'string', + 'type' => ['null', 'string'], 'format' => 'uuid', - 'nullable' => true, ], ], ]); diff --git a/src/LlamaCppClient.php b/src/LlamaCppClient.php index b0f8834595c8f32f763a7c660ac7218fc2727aa4..5905bdd946194776e7110921193f337d71219702 100644 --- a/src/LlamaCppClient.php +++ b/src/LlamaCppClient.php @@ -8,6 +8,7 @@ use CurlHandle; use Distantmagic\Resonance\Attribute\Singleton; use Generator; use JsonSerializable; +use Psr\Log\LoggerInterface; use RuntimeException; use Swoole\Coroutine\Channel; @@ -15,24 +16,21 @@ use Swoole\Coroutine\Channel; readonly class LlamaCppClient { // strlen('data: ') - public const COMPLETION_CHUNKED_DATA_PREFIX_LENGTH = 6; + private const COMPLETION_CHUNKED_DATA_PREFIX_LENGTH = 6; public function __construct( private JsonSerializer $jsonSerializer, + private LoggerInterface $logger, private LlamaCppConfiguration $llamaCppConfiguration, private LlamaCppLinkBuilder $llamaCppLinkBuilder, ) {} /** - * @return Generator<LlamaCppCompletionToken> + * @return Generator<int,LlamaCppCompletionToken,null|LlamaCppCompletionCommand> */ public function generateCompletion(LlamaCppCompletionRequest $request): Generator { - $curlHandle = $this->createCurlHandle(); - - curl_setopt($curlHandle, CURLOPT_POST, true); - - $responseChunks = $this->streamResponse($curlHandle, $request, '/completion'); + $responseChunks = $this->streamResponse($request, '/completion'); /** * @var null|string @@ -52,11 +50,19 @@ readonly class LlamaCppClient ); if (is_string($previousContent)) { - yield new LlamaCppCompletionToken( + $shouldContinue = yield new LlamaCppCompletionToken( content: $previousContent, isLast: $unserializedToken->stop, ); + if (LlamaCppCompletionCommand::Stop === $shouldContinue) { + if (!$responseChunks->channel->close()) { + throw new RuntimeException('Unable to close coroutine channel'); + } + + break; + } + $previousContent = null; } @@ -104,11 +110,7 @@ readonly class LlamaCppClient */ public function generateInfill(LlamaCppInfillRequest $request): Generator { - $curlHandle = $this->createCurlHandle(); - - curl_setopt($curlHandle, CURLOPT_POST, true); - - $responseChunks = $this->streamResponse($curlHandle, $request, '/infill'); + $responseChunks = $this->streamResponse($request, '/infill'); foreach ($responseChunks as $responseChunk) { /** @@ -185,6 +187,8 @@ readonly class LlamaCppClient $headers[] = sprintf('Authorization: Bearer %s', $this->llamaCppConfiguration->apiKey); } + curl_setopt($curlHandle, CURLOPT_FORBID_REUSE, true); + curl_setopt($curlHandle, CURLOPT_FRESH_CONNECT, true); curl_setopt($curlHandle, CURLOPT_HTTPHEADER, $headers); return $curlHandle; @@ -193,29 +197,37 @@ readonly class LlamaCppClient /** * @return SwooleChannelIterator<string> */ - private function streamResponse(CurlHandle $curlHandle, JsonSerializable $request, string $path): SwooleChannelIterator + private function streamResponse(JsonSerializable $request, string $path): SwooleChannelIterator { $channel = new Channel(1); $requestData = json_encode($request); - $cid = go(function () use ($channel, $curlHandle, $path, $requestData) { + $cid = go(function () use ($channel, $path, $requestData) { + $curlHandle = $this->createCurlHandle(); + try { + curl_setopt($curlHandle, CURLOPT_POST, true); curl_setopt($curlHandle, CURLOPT_POSTFIELDS, $requestData); curl_setopt($curlHandle, CURLOPT_RETURNTRANSFER, false); curl_setopt($curlHandle, CURLOPT_URL, $this->llamaCppLinkBuilder->build($path)); curl_setopt($curlHandle, CURLOPT_WRITEFUNCTION, static function (CurlHandle $curlHandle, string $data) use ($channel) { - $channel->push($data); + if ($channel->push($data, DM_POOL_CONNECTION_TIMEOUT)) { + return strlen($data); + } - return strlen($data); + return 0; }); - if (!curl_exec($curlHandle)) { - throw new CurlException($curlHandle); - } + $curlErrno = curl_errno($curlHandle); - $this->assertStatusCode($curlHandle, 200); + if (CURLE_WRITE_ERROR !== $curlErrno) { + throw new CurlException($curlHandle); + } + } else { + $this->assertStatusCode($curlHandle, 200); + } } finally { - curl_setopt($curlHandle, CURLOPT_WRITEFUNCTION, null); + curl_close($curlHandle); $channel->close(); } diff --git a/src/WebSocketConnectionController.php b/src/LlamaCppCompletionCommand.php similarity index 54% rename from src/WebSocketConnectionController.php rename to src/LlamaCppCompletionCommand.php index e45efe54d16f0f58bc49d67ed9b96462e963edcd..99dbf1f67ddcb539cfaca9c15db4b11575450624 100644 --- a/src/WebSocketConnectionController.php +++ b/src/LlamaCppCompletionCommand.php @@ -4,4 +4,7 @@ declare(strict_types=1); namespace Distantmagic\Resonance; -abstract readonly class WebSocketConnectionController {} +enum LlamaCppCompletionCommand +{ + case Stop; +} diff --git a/src/OAuth2ClaimReader.php b/src/OAuth2ClaimReader.php index 42895365e0d371c774bd15333059f402cc5724dc..0d51b0260b65afe28457047be146d17c65b42144 100644 --- a/src/OAuth2ClaimReader.php +++ b/src/OAuth2ClaimReader.php @@ -63,7 +63,7 @@ readonly class OAuth2ClaimReader return $this ->doctrineEntityManagerRepository - ->withEntityManager(function (EntityManagerInterface $entityManager) use ($request, $serverRequest) { + ->withEntityManager(function (EntityManagerInterface $entityManager) use ($serverRequest) { $accessTokenId = $serverRequest->getAttribute('oauth_access_token_id'); if (!is_string($accessTokenId)) { @@ -100,7 +100,7 @@ readonly class OAuth2ClaimReader throw OAuthServerException::invalidRequest('oauth_user_id'); } - $user = $this->userRepository->findUserById($request, $userId); + $user = $this->userRepository->findUserById($userId); if (!$user) { throw OAuthServerException::invalidRequest('oauth_user_id'); diff --git a/src/RPCMethodInterface.php b/src/RPCMethodInterface.php index e15559ad7c9ec81e82bb7a58cc164af199c2fac7..2e1deb24bbd32c49a87c9cb71118275918e770fd 100644 --- a/src/RPCMethodInterface.php +++ b/src/RPCMethodInterface.php @@ -4,4 +4,7 @@ declare(strict_types=1); namespace Distantmagic\Resonance; -interface RPCMethodInterface extends NameableInterface {} +interface RPCMethodInterface +{ + public function getValue(): string; +} diff --git a/src/RPCMethodValidatorInterface.php b/src/RPCMethodValidatorInterface.php index a9db4daacd4b6ef7f4e6c5fa3c8a74d9663188e8..1e8a3ea289436cc905c4c6edec5e57062718c3b2 100644 --- a/src/RPCMethodValidatorInterface.php +++ b/src/RPCMethodValidatorInterface.php @@ -16,5 +16,5 @@ interface RPCMethodValidatorInterface /** * @return array<string> */ - public function names(): array; + public function values(): array; } diff --git a/src/RPCNotification.php b/src/RPCNotification.php index c3367420f16acfafd2ac988ca14b1aa98fa38118..7ca2c4aa0d4f4c587a7816cf2056d502e0ca0ee8 100644 --- a/src/RPCNotification.php +++ b/src/RPCNotification.php @@ -4,15 +4,26 @@ declare(strict_types=1); namespace Distantmagic\Resonance; +use Stringable; + /** * @psalm-suppress PossiblyUnusedProperty * * @template TPayload */ -readonly class RPCNotification +readonly class RPCNotification implements Stringable { public function __construct( public RPCMethodInterface $method, public mixed $payload, ) {} + + public function __toString(): string + { + return json_encode([ + $this->method->getValue(), + $this->payload, + null, + ]); + } } diff --git a/src/RPCResponse.php b/src/RPCResponse.php index d0758a0709fa1f777b99987ac81901c4fd47914d..a25f63fb2be3ae606623c2575c1ef731bced407c 100644 --- a/src/RPCResponse.php +++ b/src/RPCResponse.php @@ -10,14 +10,14 @@ readonly class RPCResponse implements Stringable { public function __construct( private string $requestId, - private string|Stringable $content, + private mixed $content, ) {} public function __toString(): string { return json_encode([ $this->requestId, - (string) $this->content, + $this->content, ]); } } diff --git a/src/SessionAuthentication.php b/src/SessionAuthentication.php index 3850c35c698a4a5c4e2c9fdd99136c8f9e0f53fc..51e03212c86d500df4fc35e9b163009052e0247f 100644 --- a/src/SessionAuthentication.php +++ b/src/SessionAuthentication.php @@ -71,6 +71,6 @@ final readonly class SessionAuthentication return null; } - return $this->userRepository->findUserById($request, $userId); + return $this->userRepository->findUserById($userId); } } diff --git a/src/SingletonProvider/ConfigurationProvider/LlamaCppConfigurationProvider.php b/src/SingletonProvider/ConfigurationProvider/LlamaCppConfigurationProvider.php index 7aebce60642e033296e171e8b797e196b310697e..3999e62ee00c874f352e7c62c041058555b11eba 100644 --- a/src/SingletonProvider/ConfigurationProvider/LlamaCppConfigurationProvider.php +++ b/src/SingletonProvider/ConfigurationProvider/LlamaCppConfigurationProvider.php @@ -31,9 +31,8 @@ final readonly class LlamaCppConfigurationProvider extends ConfigurationProvider 'type' => 'object', 'properties' => [ 'apiKey' => [ - 'type' => 'string', + 'type' => ['null', 'string'], 'minLength' => 1, - 'nullable' => true, 'default' => null, ], 'host' => [ diff --git a/src/SwooleChannelIterator.php b/src/SwooleChannelIterator.php index 8e16302456aa8e6e19801cb256a3d59192d3f7a5..91b26b48e44838da6da9e4e2ecc34c7cd1427c8f 100644 --- a/src/SwooleChannelIterator.php +++ b/src/SwooleChannelIterator.php @@ -16,18 +16,27 @@ use Swoole\Coroutine\Channel; */ readonly class SwooleChannelIterator implements IteratorAggregate { - public function __construct(private Channel $channel) {} + public function __construct(public Channel $channel) {} + + public function close(): void + { + $this->channel->close(); + } /** - * @return Generator<TData> + * @return Generator<int,TData,bool> */ public function getIterator(): Generator { do { + if (SWOOLE_CHANNEL_CLOSED === $this->channel->errCode) { + return; + } + /** * @var mixed $data explicitly mixed for typechecks */ - $data = $this->channel->pop(); + $data = $this->channel->pop(DM_POOL_CONNECTION_TIMEOUT); if (false === $data) { switch ($this->channel->errCode) { diff --git a/src/UserRepositoryInterface.php b/src/UserRepositoryInterface.php index 731de6dcf4196180b187b2b271b6d9d10b0b8fe0..d36c940a4d351373fa9a9cea5ff46701502e988b 100644 --- a/src/UserRepositoryInterface.php +++ b/src/UserRepositoryInterface.php @@ -4,9 +4,7 @@ declare(strict_types=1); namespace Distantmagic\Resonance; -use Swoole\Http\Request; - interface UserRepositoryInterface { - public function findUserById(Request $request, int|string $userId): ?UserInterface; + public function findUserById(int|string $userId): ?UserInterface; } diff --git a/src/WebSocketConnection.php b/src/WebSocketConnection.php index 1473665749ea0c502ea374a81b3205437e757aed..4123e92e7373d808af35b4b51d3b6ec17ca823f9 100644 --- a/src/WebSocketConnection.php +++ b/src/WebSocketConnection.php @@ -7,11 +7,13 @@ namespace Distantmagic\Resonance; use Stringable; use Swoole\WebSocket\Server; -readonly class WebSocketConnection +class WebSocketConnection { + public WebSocketConnectionStatus $status = WebSocketConnectionStatus::Open; + public function __construct( - public Server $server, - public int $fd, + public readonly Server $server, + public readonly int $fd, ) {} public function push(string|Stringable $response): void diff --git a/src/WebSocketConnectionController/RPCConnectionController.php b/src/WebSocketConnectionController/RPCConnectionController.php deleted file mode 100644 index 8b8efe6cbca0e0252c684b572feb75d2e53fbad0..0000000000000000000000000000000000000000 --- a/src/WebSocketConnectionController/RPCConnectionController.php +++ /dev/null @@ -1,32 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Distantmagic\Resonance\WebSocketConnectionController; - -use Distantmagic\Resonance\InputValidatedData\RPCMessage; -use Distantmagic\Resonance\WebSocketAuthResolution; -use Distantmagic\Resonance\WebSocketConnection; -use Distantmagic\Resonance\WebSocketConnectionController; -use Distantmagic\Resonance\WebSocketRPCResponderAggregate; - -readonly class RPCConnectionController extends WebSocketConnectionController -{ - public function __construct( - private WebSocketRPCResponderAggregate $webSocketRPCResponderAggregate, - private WebSocketAuthResolution $webSocketAuthResolution, - private WebSocketConnection $webSocketConnection, - ) {} - - public function onRPCMessage(RPCMessage $rpcMessage): void - { - $this - ->webSocketRPCResponderAggregate - ->respond( - $this->webSocketAuthResolution, - $this->webSocketConnection, - $rpcMessage - ) - ; - } -} diff --git a/src/WebSocketConnectionStatus.php b/src/WebSocketConnectionStatus.php new file mode 100644 index 0000000000000000000000000000000000000000..fd37d59b3ddbe7ba5283a2a790266af2bd2c6d7c --- /dev/null +++ b/src/WebSocketConnectionStatus.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +enum WebSocketConnectionStatus +{ + case Closed; + case Open; + + public function isClosed(): bool + { + return self::Closed === $this; + } + + public function isOpen(): bool + { + return self::Open === $this; + } +} diff --git a/src/WebSocketProtocolController/RPCProtocolController.php b/src/WebSocketProtocolController/RPCProtocolController.php index b9545ae9dece654f69c7a71f0a38234696a26eb5..5b9e1908b0a54fcad684c049754eee60fd7d85b7 100644 --- a/src/WebSocketProtocolController/RPCProtocolController.php +++ b/src/WebSocketProtocolController/RPCProtocolController.php @@ -16,10 +16,12 @@ use Distantmagic\Resonance\SingletonCollection; use Distantmagic\Resonance\SiteAction; use Distantmagic\Resonance\WebSocketAuthResolution; use Distantmagic\Resonance\WebSocketConnection; -use Distantmagic\Resonance\WebSocketConnectionController\RPCConnectionController; +use Distantmagic\Resonance\WebSocketConnectionStatus; use Distantmagic\Resonance\WebSocketProtocol; use Distantmagic\Resonance\WebSocketProtocolController; use Distantmagic\Resonance\WebSocketProtocolException; +use Distantmagic\Resonance\WebSocketRPCConnectionControllerInterface; +use Distantmagic\Resonance\WebSocketRPCConnectionHandle; use Distantmagic\Resonance\WebSocketRPCResponderAggregate; use Ds\Map; use JsonException; @@ -38,9 +40,9 @@ use Throwable; final readonly class RPCProtocolController extends WebSocketProtocolController { /** - * @var Map<int, RPCConnectionController> + * @var Map<int, WebSocketRPCConnectionHandle> */ - private Map $connectionControllers; + private Map $connectionHandles; public function __construct( private CSRFManager $csrfManager, @@ -50,16 +52,19 @@ final readonly class RPCProtocolController extends WebSocketProtocolController private LoggerInterface $logger, private RPCMessageValidator $rpcMessageValidator, private WebSocketRPCResponderAggregate $webSocketRPCResponderAggregate, + private ?WebSocketRPCConnectionControllerInterface $webSocketRPCConnectionController = null, ) { /** - * @var Map<int, RPCConnectionController> + * @var Map<int, WebSocketRPCConnectionHandle> */ - $this->connectionControllers = new Map(); + $this->connectionHandles = new Map(); } public function isAuthorizedToConnect(Request $request): WebSocketAuthResolution { if (!is_array($request->get) || !$this->csrfManager->checkToken($request, $request->get)) { + $this->logger->debug('WebSocket: Invalid CSRF token'); + return new WebSocketAuthResolution(false); } @@ -73,14 +78,23 @@ final readonly class RPCProtocolController extends WebSocketProtocolController public function onClose(Server $server, int $fd): void { - if (!$this->connectionControllers->hasKey($fd)) { + $connectionHandle = $this->connectionHandles->get($fd, null); + + if (!$connectionHandle) { throw new RuntimeException(sprintf( 'RPC connection controller is not set and therefore it cannot be removed: %s', $fd, )); } - $this->connectionControllers->remove($fd); + $connectionHandle->webSocketConnection->status = WebSocketConnectionStatus::Closed; + + $this->webSocketRPCConnectionController?->onClose( + $connectionHandle->webSocketAuthResolution, + $connectionHandle->webSocketConnection, + ); + + $this->connectionHandles->remove($fd); } public function onMessage(Server $server, Frame $frame): void @@ -103,25 +117,31 @@ final readonly class RPCProtocolController extends WebSocketProtocolController public function onOpen(Server $server, int $fd, WebSocketAuthResolution $webSocketAuthResolution): void { - $connectionController = new RPCConnectionController( + $webSocketConnection = new WebSocketConnection($server, $fd); + $connectionHandle = new WebSocketRPCConnectionHandle( $this->webSocketRPCResponderAggregate, $webSocketAuthResolution, - new WebSocketConnection($server, $fd), + $webSocketConnection, + ); + + $this->webSocketRPCConnectionController?->onOpen( + $webSocketAuthResolution, + $webSocketConnection, ); - $this->connectionControllers->put($fd, $connectionController); + $this->connectionHandles->put($fd, $connectionHandle); } - private function getFrameController(Frame $frame): RPCConnectionController + private function getFrameController(Frame $frame): WebSocketRPCConnectionHandle { - if (!$this->connectionControllers->hasKey($frame->fd)) { + if (!$this->connectionHandles->hasKey($frame->fd)) { throw new RuntimeException(sprintf( 'RPC connection controller is not set and therefore it cannot handle a message: %s', $frame->fd, )); } - return $this->connectionControllers->get($frame->fd); + return $this->connectionHandles->get($frame->fd); } private function onException(Server $server, Frame $frame, Throwable $exception): void @@ -145,6 +165,7 @@ final readonly class RPCProtocolController extends WebSocketProtocolController private function onProtocolError(Server $server, Frame $frame, string $reason): void { + $this->logger->debug(sprintf('WebSocket Protocol Error: %s', $reason)); $server->disconnect($frame->fd, SWOOLE_WEBSOCKET_CLOSE_PROTOCOL_ERROR, $reason); } } diff --git a/src/WebSocketProtocolException/UnexpectedNotification.php b/src/WebSocketProtocolException/UnexpectedNotification.php index 509dddf83b6b85891cb4764ee457b97df54edccb..e6f1f39ae7dc63cabfd38f82cd6041a9338da643 100644 --- a/src/WebSocketProtocolException/UnexpectedNotification.php +++ b/src/WebSocketProtocolException/UnexpectedNotification.php @@ -11,6 +11,6 @@ class UnexpectedNotification extends WebSocketProtocolException { public function __construct(RPCMethodInterface $rpcMethod) { - parent::__construct('RPC method must expect a response: '.$rpcMethod->getName()); + parent::__construct('RPC method must expect a notification: '.$rpcMethod->getValue()); } } diff --git a/src/WebSocketProtocolException/UnexpectedRequest.php b/src/WebSocketProtocolException/UnexpectedRequest.php index 82bbb079de23ab79b2579e3b38c6f278cffd3e8d..4e3d64f8be53d88b1d8969efb972db53ea6cc327 100644 --- a/src/WebSocketProtocolException/UnexpectedRequest.php +++ b/src/WebSocketProtocolException/UnexpectedRequest.php @@ -11,6 +11,6 @@ class UnexpectedRequest extends WebSocketProtocolException { public function __construct(RPCMethodInterface $rpcMethod) { - parent::__construct('RPC method must not expect a response: '.$rpcMethod->getName()); + parent::__construct('RPC method must not expect a response: '.$rpcMethod->getValue()); } } diff --git a/src/WebSocketRPCConnectionController.php b/src/WebSocketRPCConnectionController.php new file mode 100644 index 0000000000000000000000000000000000000000..d4a55383bca53d975b5e9e84cbf8465d6ff13efd --- /dev/null +++ b/src/WebSocketRPCConnectionController.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +abstract readonly class WebSocketRPCConnectionController implements WebSocketRPCConnectionControllerInterface +{ + public function onClose(WebSocketAuthResolution $webSocketAuthResolution, WebSocketConnection $webSocketConnection): void {} +} diff --git a/src/WebSocketRPCConnectionControllerInterface.php b/src/WebSocketRPCConnectionControllerInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..3b2ac58922a355a181050b5d8a1219729e292f89 --- /dev/null +++ b/src/WebSocketRPCConnectionControllerInterface.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +interface WebSocketRPCConnectionControllerInterface +{ + public function onClose( + WebSocketAuthResolution $webSocketAuthResolution, + WebSocketConnection $webSocketConnection, + ): void; + + public function onOpen( + WebSocketAuthResolution $webSocketAuthResolution, + WebSocketConnection $webSocketConnection, + ): void; +} diff --git a/src/WebSocketRPCConnectionHandle.php b/src/WebSocketRPCConnectionHandle.php new file mode 100644 index 0000000000000000000000000000000000000000..06ac51d0c507ea8262462e65708a958b73285734 --- /dev/null +++ b/src/WebSocketRPCConnectionHandle.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +use Distantmagic\Resonance\InputValidatedData\RPCMessage; + +readonly class WebSocketRPCConnectionHandle +{ + public function __construct( + public WebSocketRPCResponderAggregate $webSocketRPCResponderAggregate, + public WebSocketAuthResolution $webSocketAuthResolution, + public WebSocketConnection $webSocketConnection, + ) {} + + public function onRPCMessage(RPCMessage $rpcMessage): void + { + $this + ->webSocketRPCResponderAggregate + ->respond( + $this->webSocketAuthResolution, + $this->webSocketConnection, + $rpcMessage + ) + ; + } +} diff --git a/src/WebSocketRPCResponderAggregate.php b/src/WebSocketRPCResponderAggregate.php index e6476f401065d2c9d8fe8d7cf548a97b5e4f66ee..9241dcaef3f288d215e7113a97135af01c093040 100644 --- a/src/WebSocketRPCResponderAggregate.php +++ b/src/WebSocketRPCResponderAggregate.php @@ -34,7 +34,7 @@ readonly class WebSocketRPCResponderAggregate private function selectResponder(RPCMessage $rpcMessage): WebSocketRPCResponderInterface { if (!$this->rpcResponders->hasKey($rpcMessage->method)) { - throw new DomainException('Unsupported RPC method: '.$rpcMessage->method->getName()); + throw new DomainException('Unsupported RPC method: '.$rpcMessage->method->getValue()); } return $this->rpcResponders->get($rpcMessage->method); diff --git a/src/WebSocketServerController.php b/src/WebSocketServerController.php index a9d8b72333b68c6b388d4e7020e3a5ec5e51a8f4..4abeead7c5945f55e934905917589ecc7e487699 100644 --- a/src/WebSocketServerController.php +++ b/src/WebSocketServerController.php @@ -7,7 +7,6 @@ namespace Distantmagic\Resonance; use Distantmagic\Resonance\Attribute\Singleton; use Ds\Map; use Psr\Log\LoggerInterface; -use Swoole\Event; use Swoole\Http\Request; use Swoole\Http\Response; use Swoole\WebSocket\Frame; @@ -23,6 +22,14 @@ final readonly class WebSocketServerController */ private const HANDSHAKE_MAGIC_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + private const MESSAGE_INVALID_HANDSHAKE = 'WebSocket invalid handshake'; + private const MESSAGE_INVALID_SEC_WEBSOCKET = 'Invalid sec-websocket-key'; + private const MESSAGE_NO_REQUEST_HEADERS = 'No request headers'; + private const MESSAGE_NO_SEC_WEBSOCKET = 'Missing sec-websocket-key'; + private const MESSAGE_NO_WEBSOCKET_CONTROLLER = 'WebSocket controller is not set for connection'; + private const MESSAGE_NOT_AUTHORIZED = 'Not authorized to open WebSocket connection'; + private const MESSAGE_PROTOCOL_NOT_SUPPORTED = 'None of the requested protocols is supported'; + private const MESSAGE_UNEXPECTED_ONOPEN = 'Websocket open event fired despite implementing custom handshake'; private const SEC_WEBSOCKET_KEY_BASE64_DECODED_BYTES_STRLEN = 16; private const SEC_WEBSOCKET_KEY_BASE64_STRLEN = 24; @@ -51,13 +58,13 @@ final readonly class WebSocketServerController public function onHandshake(Server $server, Request $request, Response $response): void { if (!is_array($request->header)) { - $this->onInvalidHandshake($response, 'No request headers'); + $this->onInvalidHandshake($response, self::MESSAGE_NO_REQUEST_HEADERS); return; } if (!isset($request->header['sec-websocket-key'])) { - $this->onInvalidHandshake($response, 'Missing sec-websocket-key'); + $this->onInvalidHandshake($response, self::MESSAGE_NO_SEC_WEBSOCKET); return; } @@ -68,7 +75,7 @@ final readonly class WebSocketServerController $secWebSocketKey = $request->header['sec-websocket-key']; if (!$this->isSecWebSocketKeyValid($secWebSocketKey)) { - $this->onInvalidHandshake($response, 'Invalid sec-websocket-key'); + $this->onInvalidHandshake($response, self::MESSAGE_INVALID_SEC_WEBSOCKET); return; } @@ -76,7 +83,7 @@ final readonly class WebSocketServerController $controllerResolution = $this->protocolControllerAggregate->resolveController($request); if (!$controllerResolution) { - $this->onInvalidHandshake($response, 'None of the requested protocols is supported'); + $this->onInvalidHandshake($response, self::MESSAGE_PROTOCOL_NOT_SUPPORTED); return; } @@ -84,7 +91,8 @@ final readonly class WebSocketServerController $authResolution = $controllerResolution->controller->isAuthorizedToConnect($request); if (!$authResolution->isAuthorizedToConnect) { - $response->status(403, 'Not authorized to open connection'); + $this->logger->debug(self::MESSAGE_NOT_AUTHORIZED); + $response->status(403, self::MESSAGE_NOT_AUTHORIZED); $response->end(); return; @@ -93,10 +101,6 @@ final readonly class WebSocketServerController $fd = $request->fd; $this->protocolControllerAssignments->put($fd, $controllerResolution->controller); - Event::defer(static function () use ($authResolution, $controllerResolution, $fd, $server) { - $controllerResolution->controller->onOpen($server, $fd, $authResolution); - }); - $secWebSocketAccept = base64_encode(sha1($secWebSocketKey.self::HANDSHAKE_MAGIC_GUID, true)); $response->header('connection', 'Upgrade'); @@ -107,12 +111,14 @@ final readonly class WebSocketServerController $response->status(101); $response->end(); + + $controllerResolution->controller->onOpen($server, $fd, $authResolution); } public function onMessage(Server $server, Frame $frame): void { if (!$this->protocolControllerAssignments->hasKey($frame->fd)) { - $this->logger->error('websocket controller is not set for connection: '.(string) $frame->fd); + $this->logger->error(self::MESSAGE_NO_WEBSOCKET_CONTROLLER); $server->disconnect($frame->fd, SWOOLE_WEBSOCKET_CLOSE_SERVER_ERROR); return; @@ -123,7 +129,7 @@ final readonly class WebSocketServerController public function onOpen(Server $server, Request $request): void { - $this->logger->error('websocket open event fired despite implementing custom handshake'); + $this->logger->error(self::MESSAGE_UNEXPECTED_ONOPEN); $server->disconnect($request->fd, SWOOLE_WEBSOCKET_CLOSE_SERVER_ERROR); } @@ -144,7 +150,7 @@ final readonly class WebSocketServerController private function onInvalidHandshake(Response $response, string $reason): void { - $this->logger->debug('websocket invalid handshake: '.$reason); + $this->logger->debug(sprintf('%s: %s', self::MESSAGE_INVALID_HANDSHAKE, $reason)); $response->status(400, $reason); $response->end();