diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b78f4dca2..d9d2d331c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,41 +20,16 @@ jobs: fail-fast: false matrix: php: - - '7.3' - - '7.4' - '8.0' - '8.1' laravel: - - 6.* - - 7.* - - 8.* + - 9.* prefer: - 'prefer-lowest' - 'prefer-stable' include: - - laravel: '6.*' - testbench: '4.*' - phpunit: '^8.5.8|^9.3.3' - - laravel: '7.*' - testbench: '5.*' - phpunit: '^8.5.8|^9.3.3' - - laravel: '8.*' - testbench: '6.*' - phpunit: '^9.3.3' - exclude: - - php: '8.0' - laravel: 6.* - prefer: 'prefer-lowest' - - php: '8.0' - laravel: 7.* - prefer: 'prefer-lowest' - - php: '8.1' - laravel: 6.* - - php: '8.1' - laravel: 7.* - - php: '8.1' - laravel: 8.* - prefer: 'prefer-lowest' + - laravel: '9.*' + testbench: '7.*' name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} --${{ matrix.prefer }} @@ -68,6 +43,13 @@ jobs: extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv coverage: pcov + - name: Setup MySQL + uses: haltuf/mysql-action@master + with: + mysql version: '8.0' + mysql database: 'websockets_test' + mysql root password: 'password' + - name: Setup Redis uses: supercharge/redis-github-action@1.1.0 with: @@ -81,7 +63,7 @@ jobs: - name: Install dependencies run: | - composer require "laravel/framework:${{ matrix.laravel }}" "phpunit/phpunit:${{ matrix.phpunit }}" "orchestra/testbench-browser-kit:${{ matrix.testbench }}" "orchestra/database:${{ matrix.testbench }}" --no-interaction --no-update + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench-browser-kit:${{ matrix.testbench }}" "orchestra/database:${{ matrix.testbench }}" --no-interaction --no-update composer update --${{ matrix.prefer }} --prefer-dist --no-interaction --no-suggest - name: Run tests for Local diff --git a/composer.json b/composer.json index f68f053bb8..e0760ca84d 100644 --- a/composer.json +++ b/composer.json @@ -29,27 +29,33 @@ } ], "require": { - "cboden/ratchet": "^0.4.1", - "clue/redis-react": "^2.5", + "php": "^8.0|^8.1", + "cboden/ratchet": "^0.4.4", + "clue/block-react": "^1.5", + "clue/reactphp-sqlite": "^1.0", + "clue/redis-react": "^2.6", + "doctrine/dbal": "^2.9", "evenement/evenement": "^2.0|^3.0", "facade/ignition-contracts": "^1.0", - "guzzlehttp/psr7": "^1.7|^2.0", - "illuminate/broadcasting": "^6.3|^7.0|^8.0|^9.0", - "illuminate/console": "^6.3|^7.0|^8.0|^9.0", - "illuminate/http": "^6.3|^7.0|^8.0|^9.0", - "illuminate/queue": "^6.3|^7.0|^8.0|^9.0", - "illuminate/routing": "^6.3|^7.0|^8.0|^9.0", - "illuminate/support": "^6.3|^7.0|^8.0|^9.0", - "pusher/pusher-php-server": "^3.0|^4.0|^5.0|^6.0|^7.0", + "guzzlehttp/psr7": "^1.5", + "illuminate/broadcasting": "^9.0", + "illuminate/console": "^9.0", + "illuminate/http": "^9.0", + "illuminate/queue": "^9.0", + "illuminate/routing": "^9.0", + "illuminate/support": "^9.0", + "pusher/pusher-php-server": "^6.0|^7.0", + "react/mysql": "^0.5", "react/promise": "^2.8", - "symfony/http-kernel": "^4.4|^5.4|^6.0", + "symfony/http-kernel": "^5.0|^6.0", "symfony/psr-http-message-bridge": "^1.1|^2.0" }, "require-dev": { - "clue/block-react": "^1.4", + "clue/buzz-react": "^2.9", "laravel/legacy-factories": "^1.1", - "orchestra/testbench-browser-kit": "^4.0|^5.0|^6.0", - "phpunit/phpunit": "^8.5.8|^9.3.3" + "orchestra/testbench-browser-kit": "^7.0", + "phpunit/phpunit": "^9.0", + "ratchet/pawl": "^0.3.5" }, "suggest": { "ext-pcntl": "Running the server needs pcntl to listen to command signals and soft-shutdown.", @@ -71,7 +77,8 @@ "config": { "sort-packages": true }, - "minimum-stability": "stable", + "minimum-stability": "dev", + "prefer-stable": true, "extra": { "laravel": { "providers": [ diff --git a/config/websockets.php b/config/websockets.php index 681bb6bdc1..a321ace98b 100644 --- a/config/websockets.php +++ b/config/websockets.php @@ -37,13 +37,40 @@ | the use of the TCP protocol based on, for example, a list of allowed | applications. | By default, it uses the defined array in the config file, but you can - | anytime implement the same interface as the class and add your own - | custom method to retrieve the apps. + | choose to use SQLite or MySQL application managers, or define a + | custom application manager. | */ 'app' => \BeyondCode\LaravelWebSockets\Apps\ConfigAppManager::class, + /* + |-------------------------------------------------------------------------- + | SQLite application manager + |-------------------------------------------------------------------------- + | + | The SQLite database to use when using the SQLite application manager. + | + */ + + 'sqlite' => [ + 'database' => storage_path('laravel-websockets.sqlite'), + ], + + /* + |-------------------------------------------------------------------------- + | MySql application manager + |-------------------------------------------------------------------------- + | + | The MySQL database connection to use. + | + */ + + 'mysql' => [ + 'connection' => env('DB_CONNECTION', 'mysql'), + + 'table' => 'websockets_apps', + ], ], /* diff --git a/database/migrations/0000_00_00_000000_create_websockets_apps_table.php b/database/migrations/0000_00_00_000000_create_websockets_apps_table.php new file mode 100644 index 0000000000..7c22401fd9 --- /dev/null +++ b/database/migrations/0000_00_00_000000_create_websockets_apps_table.php @@ -0,0 +1,40 @@ +string('id')->index(); + $table->string('key'); + $table->string('secret'); + $table->string('name'); + $table->string('host')->nullable(); + $table->string('path')->nullable(); + $table->boolean('enable_client_messages')->default(false); + $table->boolean('enable_statistics')->default(true); + $table->unsignedInteger('capacity')->nullable(); + $table->string('allowed_origins'); + $table->nullableTimestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('websockets_apps'); + } +} diff --git a/database/migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php b/database/migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php index 1b89b4af31..0989f288c5 100644 --- a/database/migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php +++ b/database/migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php @@ -16,9 +16,9 @@ public function up() Schema::create('websockets_statistics_entries', function (Blueprint $table) { $table->increments('id'); $table->string('app_id'); - $table->integer('peak_connection_count'); - $table->integer('websocket_message_count'); - $table->integer('api_message_count'); + $table->integer('peak_connections_count'); + $table->integer('websocket_messages_count'); + $table->integer('api_messages_count'); $table->nullableTimestamps(); }); } diff --git a/database/migrations/0000_00_00_000000_rename_statistics_counters.php b/database/migrations/0000_00_00_000000_rename_statistics_counters.php deleted file mode 100644 index 95b23f4db7..0000000000 --- a/database/migrations/0000_00_00_000000_rename_statistics_counters.php +++ /dev/null @@ -1,44 +0,0 @@ -renameColumn('peak_connection_count', 'peak_connections_count'); - }); - Schema::table('websockets_statistics_entries', function (Blueprint $table) { - $table->renameColumn('websocket_message_count', 'websocket_messages_count'); - }); - Schema::table('websockets_statistics_entries', function (Blueprint $table) { - $table->renameColumn('api_message_count', 'api_messages_count'); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::table('websockets_statistics_entries', function (Blueprint $table) { - $table->renameColumn('peak_connections_count', 'peak_connection_count'); - }); - Schema::table('websockets_statistics_entries', function (Blueprint $table) { - $table->renameColumn('websocket_messages_count', 'websocket_message_count'); - }); - Schema::table('websockets_statistics_entries', function (Blueprint $table) { - $table->renameColumn('api_messages_count', 'api_message_count'); - }); - } -} diff --git a/database/migrations/sqlite/0000_00_00_000000_create_apps_table.sql b/database/migrations/sqlite/0000_00_00_000000_create_apps_table.sql new file mode 100644 index 0000000000..c85f01a223 --- /dev/null +++ b/database/migrations/sqlite/0000_00_00_000000_create_apps_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS apps ( + id STRING NOT NULL, + key STRING NOT NULL, + secret STRING NOT NULL, + name STRING NOT NULL, + host STRING NULLABLE, + path STRING NULLABLE, + enable_client_messages BOOLEAN DEFAULT 0, + enable_statistics BOOLEAN DEFAULT 1, + capacity INTEGER NULLABLE, + allowed_origins STRING NULLABLE +) diff --git a/resources/views/apps.blade.php b/resources/views/apps.blade.php new file mode 100644 index 0000000000..c7fab06310 --- /dev/null +++ b/resources/views/apps.blade.php @@ -0,0 +1,183 @@ +@extends('websockets::layout') + +@section('title') + Apps +@endsection + +@section('content') +
+
+ @csrf +
+
+
+

+ Add new app +

+
+ + @if($errors->isNotEmpty()) +
+ @foreach($errors->all() as $error) + {{ $error }}
+ @endforeach +
+ @endif + +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+ + +
+
+
+
+
+ +
+
+
+ + +
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
+ Name + + Allowed origins + + Statistics + + Client Messages +
+ @{{ app.name }} + + @{{ app.allowed_origins || '*' }} + + Yes + No + + Yes + No + + Installation instructions + Delete +
+
+
+ +
+

Modify your .env file:

+
PUSHER_APP_HOST=@{{ app.host === null ? window.location.hostname : app.host }}
+PUSHER_APP_PORT={{ $port }}
+PUSHER_APP_KEY=@{{ app.key }}
+PUSHER_APP_ID=@{{ app.id }}
+PUSHER_APP_SECRET=@{{ app.secret }}
+PUSHER_APP_SCHEME=https
+MIX_PUSHER_APP_HOST="${PUSHER_APP_HOST}"
+MIX_PUSHER_APP_PORT="${PUSHER_APP_PORT}"
+MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
+
+ +
+@endsection + +@section('scripts') + +@endsection diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 9343967841..c449a20220 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -1,32 +1,13 @@ - - - +@extends('websockets::layout') - WebSockets Dashboard +@section('title') + Dashboard +@endsection - - - - - - - - - - - - - +@section('content')
@@ -197,256 +178,258 @@ class="flex flex-col my-6"
- - - - - + + + + + - - - - - + + + + +
- Type - - Details - - Time -
+ Type + + Details + + Time +
-
- @{{ log.type }} -
-
-
@{{ log.details }}
-
- @{{ log.time }} -
+
+ @{{ log.type }} +
+
+
@{{ log.details }}
+
+ @{{ log.time }} +
- - - + }); + +@endsection diff --git a/resources/views/layout.blade.php b/resources/views/layout.blade.php new file mode 100644 index 0000000000..4772d3e931 --- /dev/null +++ b/resources/views/layout.blade.php @@ -0,0 +1,93 @@ + + + + + Laravel WebSockets + + + + + + + + + + + + + + +
+ + + +
+
+
+

+ @yield('title') +

+
+
+
+
+ @yield('content') +
+
+
+
+ +@yield('scripts') + + diff --git a/src/API/Controller.php b/src/API/Controller.php index c413e9c1bf..8e4513b931 100644 --- a/src/API/Controller.php +++ b/src/API/Controller.php @@ -17,6 +17,7 @@ use Pusher\Pusher; use Ratchet\ConnectionInterface; use Ratchet\Http\HttpServerInterface; +use React\Promise\Deferred; use React\Promise\PromiseInterface; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -52,13 +53,6 @@ abstract class Controller implements HttpServerInterface */ protected $channelManager; - /** - * The app attached with this request. - * - * @var \BeyondCode\LaravelWebSockets\Apps\App|null - */ - protected $app; - /** * Initialize the request. * @@ -184,26 +178,43 @@ protected function handleRequest(ConnectionInterface $connection) $laravelRequest = Request::createFromBase((new HttpFoundationFactory)->createRequest($serverRequest)); - $this->ensureValidAppId($laravelRequest->get('appId')) - ->ensureValidSignature($laravelRequest); + $this + ->ensureValidAppId($laravelRequest->appId) + ->then(function ($app) use ($laravelRequest, $connection) { + try { + $this->ensureValidSignature($app, $laravelRequest); + } catch (HttpException $exception) { + $this->onError($connection, $exception); - // Invoke the controller action - $response = $this($laravelRequest); + return; + } - // Allow for async IO in the controller action - if ($response instanceof PromiseInterface) { - $response->then(function ($response) use ($connection) { - $this->sendAndClose($connection, $response); - }); + // Invoke the controller action + try { + $response = $this($laravelRequest); + } catch (HttpException $exception) { + $this->onError($connection, $exception); - return; - } + return; + } - if ($response instanceof HttpException) { - throw $response; - } + // Allow for async IO in the controller action + if ($response instanceof PromiseInterface) { + $response->then(function ($response) use ($connection) { + $this->sendAndClose($connection, $response); + }); + + return; + } - $this->sendAndClose($connection, $response); + if ($response instanceof HttpException) { + $this->onError($connection, $response); + + return; + } + + $this->sendAndClose($connection, $response); + }); } /** @@ -222,29 +233,34 @@ protected function sendAndClose(ConnectionInterface $connection, $response) * Ensure app existence. * * @param mixed $appId - * @return $this + * @return PromiseInterface * * @throws \Symfony\Component\HttpKernel\Exception\HttpException */ public function ensureValidAppId($appId) { - if (! $appId || ! $this->app = App::findById($appId)) { - throw new HttpException(401, "Unknown app id `{$appId}` provided."); - } + $deferred = new Deferred(); + + App::findById($appId) + ->then(function ($app) use ($appId, $deferred) { + if (! $app) { + throw new HttpException(401, "Unknown app id `{$appId}` provided."); + } + $deferred->resolve($app); + }); - return $this; + return $deferred->promise(); } /** * Ensure signature integrity coming from an * authorized application. * - * @param \GuzzleHttp\Psr7\ServerRequest $request + * @param App $app + * @param Request $request * @return $this - * - * @throws \Symfony\Component\HttpKernel\Exception\HttpException */ - protected function ensureValidSignature(Request $request) + protected function ensureValidSignature(App $app, Request $request) { // The `auth_signature` & `body_md5` parameters are not included when calculating the `auth_signature` value. // The `appId`, `appKey` & `channelName` parameters are actually route parameters and are never supplied by the client. @@ -261,7 +277,7 @@ protected function ensureValidSignature(Request $request) $signature = "{$request->getMethod()}\n/{$request->path()}\n".Pusher::array_implode('=', '&', $params); - $authSignature = hash_hmac('sha256', $signature, $this->app->secret); + $authSignature = hash_hmac('sha256', $signature, $app->secret); if ($authSignature !== $request->get('auth_signature')) { throw new HttpException(401, 'Invalid auth signature provided.'); diff --git a/src/API/TriggerEvent.php b/src/API/TriggerEvent.php index ec802ae82b..b5120278b1 100644 --- a/src/API/TriggerEvent.php +++ b/src/API/TriggerEvent.php @@ -5,6 +5,7 @@ use BeyondCode\LaravelWebSockets\DashboardLogger; use BeyondCode\LaravelWebSockets\Facades\StatisticsCollector; use Illuminate\Http\Request; +use React\Promise\Deferred; class TriggerEvent extends Controller { @@ -16,10 +17,14 @@ class TriggerEvent extends Controller */ public function __invoke(Request $request) { - $channels = $request->channels ?: []; + if ($request->has('channel')) { + $channels = [$request->get('channel')]; + } else { + $channels = $request->channels ?: []; - if (is_string($channels)) { - $channels = [$channels]; + if (is_string($channels)) { + $channels = [$channels]; + } } foreach ($channels as $channelName) { @@ -49,17 +54,24 @@ public function __invoke(Request $request) $request->appId, $request->socket_id, $channelName, (object) $payload ); - if ($this->app->statisticsEnabled) { - StatisticsCollector::apiMessage($request->appId); - } + $deferred = new Deferred(); - DashboardLogger::log($request->appId, DashboardLogger::TYPE_API_MESSAGE, [ - 'event' => $request->name, - 'channel' => $channelName, - 'payload' => $request->data, - ]); + $this->ensureValidAppId($request->appId) + ->then(function ($app) use ($request, $channelName, $deferred) { + if ($app->statisticsEnabled) { + StatisticsCollector::apiMessage($request->appId); + } + + DashboardLogger::log($request->appId, DashboardLogger::TYPE_API_MESSAGE, [ + 'event' => $request->name, + 'channel' => $channelName, + 'payload' => $request->data, + ]); + + $deferred->resolve((object) []); + }); } - return (object) []; + return $deferred->promise(); } } diff --git a/src/Apps/App.php b/src/Apps/App.php index 19d10f6bba..e2f7194c35 100644 --- a/src/Apps/App.php +++ b/src/Apps/App.php @@ -3,6 +3,7 @@ namespace BeyondCode\LaravelWebSockets\Apps; use BeyondCode\LaravelWebSockets\Contracts\AppManager; +use React\Promise\PromiseInterface; class App { @@ -40,7 +41,7 @@ class App * Find the app by id. * * @param string|int $appId - * @return \BeyondCode\LaravelWebSockets\Apps\App|null + * @return PromiseInterface */ public static function findById($appId) { @@ -51,9 +52,9 @@ public static function findById($appId) * Find the app by app key. * * @param string $appKey - * @return \BeyondCode\LaravelWebSockets\Apps\App|null + * @return PromiseInterface */ - public static function findByKey($appKey): ?self + public static function findByKey($appKey): PromiseInterface { return app(AppManager::class)->findByKey($appKey); } @@ -62,9 +63,9 @@ public static function findByKey($appKey): ?self * Find the app by app secret. * * @param string $appSecret - * @return \BeyondCode\LaravelWebSockets\Apps\App|null + * @return PromiseInterface */ - public static function findBySecret($appSecret): ?self + public static function findBySecret($appSecret): PromiseInterface { return app(AppManager::class)->findBySecret($appSecret); } diff --git a/src/Apps/ConfigAppManager.php b/src/Apps/ConfigAppManager.php index eb3d5dbadd..0b1b52f3c0 100644 --- a/src/Apps/ConfigAppManager.php +++ b/src/Apps/ConfigAppManager.php @@ -3,6 +3,8 @@ namespace BeyondCode\LaravelWebSockets\Apps; use BeyondCode\LaravelWebSockets\Contracts\AppManager; +use React\Promise\PromiseInterface; +use function React\Promise\resolve as resolvePromise; class ConfigAppManager implements AppManager { @@ -26,54 +28,64 @@ public function __construct() /** * Get all apps. * - * @return array[\BeyondCode\LaravelWebSockets\Apps\App] + * @return PromiseInterface */ - public function all(): array + public function all(): PromiseInterface { - return $this->apps + return resolvePromise($this->apps ->map(function (array $appAttributes) { return $this->convertIntoApp($appAttributes); }) - ->toArray(); + ->toArray()); } /** * Get app by id. * * @param string|int $appId - * @return \BeyondCode\LaravelWebSockets\Apps\App|null + * @return PromiseInterface */ - public function findById($appId): ?App + public function findById($appId): PromiseInterface { - return $this->convertIntoApp( + return resolvePromise($this->convertIntoApp( $this->apps->firstWhere('id', $appId) - ); + )); } /** * Get app by app key. * * @param string $appKey - * @return \BeyondCode\LaravelWebSockets\Apps\App|null + * @return PromiseInterface */ - public function findByKey($appKey): ?App + public function findByKey($appKey): PromiseInterface { - return $this->convertIntoApp( + return resolvePromise($this->convertIntoApp( $this->apps->firstWhere('key', $appKey) - ); + )); } /** * Get app by secret. * * @param string $appSecret - * @return \BeyondCode\LaravelWebSockets\Apps\App|null + * @return PromiseInterface */ - public function findBySecret($appSecret): ?App + public function findBySecret($appSecret): PromiseInterface { - return $this->convertIntoApp( + return resolvePromise($this->convertIntoApp( $this->apps->firstWhere('secret', $appSecret) - ); + )); + } + + /** + * @inheritDoc + */ + public function createApp($appData): PromiseInterface + { + $this->apps->push($appData); + + return resolvePromise(); } /** @@ -107,8 +119,8 @@ protected function convertIntoApp(?array $appAttributes): ?App } $app - ->enableClientMessages($appAttributes['enable_client_messages']) - ->enableStatistics($appAttributes['enable_statistics']) + ->enableClientMessages((bool) $appAttributes['enable_client_messages']) + ->enableStatistics((bool) $appAttributes['enable_statistics']) ->setCapacity($appAttributes['capacity'] ?? null) ->setAllowedOrigins($appAttributes['allowed_origins'] ?? []); diff --git a/src/Apps/MysqlAppManager.php b/src/Apps/MysqlAppManager.php new file mode 100644 index 0000000000..fe82a7ee2d --- /dev/null +++ b/src/Apps/MysqlAppManager.php @@ -0,0 +1,171 @@ +database = $database; + } + + protected function getTableName(): string + { + return config('websockets.managers.mysql.table'); + } + + /** + * Get all apps. + * + * @return PromiseInterface + */ + public function all(): PromiseInterface + { + $deferred = new Deferred(); + + $this->database->query('SELECT * FROM `'.$this->getTableName().'`') + ->then(function (QueryResult $result) use ($deferred) { + $deferred->resolve($result->resultRows); + }, function ($error) use ($deferred) { + $deferred->reject($error); + }); + + return $deferred->promise(); + } + + /** + * Get app by id. + * + * @param string|int $appId + * @return PromiseInterface + */ + public function findById($appId): PromiseInterface + { + $deferred = new Deferred(); + + $this->database->query('SELECT * from `'.$this->getTableName().'` WHERE `id` = ?', [$appId]) + ->then(function (QueryResult $result) use ($deferred) { + $deferred->resolve($this->convertIntoApp($result->resultRows[0])); + }, function ($error) use ($deferred) { + $deferred->reject($error); + }); + + return $deferred->promise(); + } + + /** + * Get app by app key. + * + * @param string $appKey + * @return PromiseInterface + */ + public function findByKey($appKey): PromiseInterface + { + $deferred = new Deferred(); + + $this->database->query('SELECT * from `'.$this->getTableName().'` WHERE `key` = ?', [$appKey]) + ->then(function (QueryResult $result) use ($deferred) { + $deferred->resolve($this->convertIntoApp($result->resultRows[0])); + }, function ($error) use ($deferred) { + $deferred->reject($error); + }); + + return $deferred->promise(); + } + + /** + * Get app by secret. + * + * @param string $appSecret + * @return PromiseInterface + */ + public function findBySecret($appSecret): PromiseInterface + { + $deferred = new Deferred(); + + $this->database->query('SELECT * from `'.$this->getTableName().'` WHERE `secret` = ?', [$appSecret]) + ->then(function (QueryResult $result) use ($deferred) { + $deferred->resolve($this->convertIntoApp($result->resultRows[0])); + }, function ($error) use ($deferred) { + $deferred->reject($error); + }); + + return $deferred->promise(); + } + + /** + * Map the app into an App instance. + * + * @param array|null $app + * @return \BeyondCode\LaravelWebSockets\Apps\App|null + */ + protected function convertIntoApp(?array $appAttributes): ?App + { + if (! $appAttributes) { + return null; + } + + $app = new App( + $appAttributes['id'], + $appAttributes['key'], + $appAttributes['secret'] + ); + + if (isset($appAttributes['name'])) { + $app->setName($appAttributes['name']); + } + + if (isset($appAttributes['host'])) { + $app->setHost($appAttributes['host']); + } + + if (isset($appAttributes['path'])) { + $app->setPath($appAttributes['path']); + } + + $app + ->enableClientMessages((bool) $appAttributes['enable_client_messages']) + ->enableStatistics((bool) $appAttributes['enable_statistics']) + ->setCapacity($appAttributes['capacity'] ?? null) + ->setAllowedOrigins(array_filter(explode(',', $appAttributes['allowed_origins']))); + + return $app; + } + + /** + * @inheritDoc + */ + public function createApp($appData): PromiseInterface + { + $deferred = new Deferred(); + + $this->database->query( + 'INSERT INTO `'.$this->getTableName().'` (`id`, `key`, `secret`, `name`, `enable_client_messages`, `enable_statistics`, `allowed_origins`, `capacity`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [$appData['id'], $appData['key'], $appData['secret'], $appData['name'], $appData['enable_client_messages'], $appData['enable_statistics'], $appData['allowed_origins'] ?? '', $appData['capacity'] ?? null]) + ->then(function () use ($deferred) { + $deferred->resolve(); + }, function ($error) use ($deferred) { + $deferred->reject($error); + }); + + return $deferred->promise(); + } +} diff --git a/src/Apps/SQLiteAppManager.php b/src/Apps/SQLiteAppManager.php new file mode 100644 index 0000000000..a265b40f37 --- /dev/null +++ b/src/Apps/SQLiteAppManager.php @@ -0,0 +1,167 @@ +database = $database; + } + + /** + * Get all apps. + * + * @return PromiseInterface + */ + public function all(): PromiseInterface + { + $deferred = new Deferred(); + + $this->database->query('SELECT * FROM `apps`') + ->then(function (Result $result) use ($deferred) { + $deferred->resolve($result->rows); + }, function ($error) use ($deferred) { + $deferred->reject($error); + }); + + return $deferred->promise(); + } + + /** + * Get app by id. + * + * @param string|int $appId + * @return PromiseInterface + */ + public function findById($appId): PromiseInterface + { + $deferred = new Deferred(); + + $this->database->query('SELECT * from apps WHERE `id` = :id', ['id' => $appId]) + ->then(function (Result $result) use ($deferred) { + $deferred->resolve($this->convertIntoApp($result->rows[0])); + }, function ($error) use ($deferred) { + $deferred->reject($error); + }); + + return $deferred->promise(); + } + + /** + * Get app by app key. + * + * @param string $appKey + * @return PromiseInterface + */ + public function findByKey($appKey): PromiseInterface + { + $deferred = new Deferred(); + + $this->database->query('SELECT * from apps WHERE `key` = :key', ['key' => $appKey]) + ->then(function (Result $result) use ($deferred) { + $deferred->resolve($this->convertIntoApp($result->rows[0])); + }, function ($error) use ($deferred) { + $deferred->reject($error); + }); + + return $deferred->promise(); + } + + /** + * Get app by secret. + * + * @param string $appSecret + * @return PromiseInterface + */ + public function findBySecret($appSecret): PromiseInterface + { + $deferred = new Deferred(); + + $this->database->query('SELECT * from apps WHERE `secret` = :secret', ['secret' => $appSecret]) + ->then(function (Result $result) use ($deferred) { + $deferred->resolve($this->convertIntoApp($result->rows[0])); + }, function ($error) use ($deferred) { + $deferred->reject($error); + }); + + return $deferred->promise(); + } + + /** + * Map the app into an App instance. + * + * @param array|null $app + * @return \BeyondCode\LaravelWebSockets\Apps\App|null + */ + protected function convertIntoApp(?array $appAttributes): ?App + { + if (! $appAttributes) { + return null; + } + + $app = new App( + $appAttributes['id'], + $appAttributes['key'], + $appAttributes['secret'] + ); + + if (isset($appAttributes['name'])) { + $app->setName($appAttributes['name']); + } + + if (isset($appAttributes['host'])) { + $app->setHost($appAttributes['host']); + } + + if (isset($appAttributes['path'])) { + $app->setPath($appAttributes['path']); + } + + $app + ->enableClientMessages((bool) $appAttributes['enable_client_messages']) + ->enableStatistics((bool) $appAttributes['enable_statistics']) + ->setCapacity($appAttributes['capacity'] ?? null) + ->setAllowedOrigins(array_filter(explode(',', $appAttributes['allowed_origins']))); + + return $app; + } + + /** + * @inheritDoc + */ + public function createApp($appData): PromiseInterface + { + $deferred = new Deferred(); + + $this->database->query(' + INSERT INTO apps (id, key, secret, name, host, path, enable_client_messages, enable_statistics, capacity, allowed_origins) + VALUES (:id, :key, :secret, :name, :host, :path, :enable_client_messages, :enable_statistics, :capacity, :allowed_origins) + ', $appData) + ->then(function (Result $result) use ($deferred) { + $deferred->resolve(); + }, function ($error) use ($deferred) { + $deferred->reject($error); + }); + + return $deferred->promise(); + } +} diff --git a/src/Cache/ArrayLock.php b/src/Cache/ArrayLock.php new file mode 100644 index 0000000000..e16e7faa52 --- /dev/null +++ b/src/Cache/ArrayLock.php @@ -0,0 +1,55 @@ +lock = new LaravelLock($store, $name, $seconds, $owner); + } + + public function acquire(): PromiseInterface + { + return Helpers::createFulfilledPromise($this->lock->acquire()); + } + + public function get($callback = null): PromiseInterface + { + return $this->lock->get($callback); + } + + public function release(): PromiseInterface + { + return Helpers::createFulfilledPromise($this->lock->release()); + } +} diff --git a/src/Cache/Lock.php b/src/Cache/Lock.php new file mode 100644 index 0000000000..907e40a40d --- /dev/null +++ b/src/Cache/Lock.php @@ -0,0 +1,46 @@ +name = $name; + $this->seconds = $seconds; + $this->owner = $owner; + } + + abstract public function acquire(): PromiseInterface; + + abstract public function get($callback = null): PromiseInterface; + + abstract public function release(): PromiseInterface; +} diff --git a/src/Cache/RedisLock.php b/src/Cache/RedisLock.php new file mode 100644 index 0000000000..a699d35504 --- /dev/null +++ b/src/Cache/RedisLock.php @@ -0,0 +1,69 @@ +redis = $redis; + } + + public function acquire(): PromiseInterface + { + $promise = new Deferred(); + + if ($this->seconds > 0) { + $this->redis + ->set($this->name, $this->owner, 'EX', $this->seconds, 'NX') + ->then(function ($result) use ($promise) { + $promise->resolve($result === true); + }); + } else { + $this->redis + ->setnx($this->name, $this->owner) + ->then(function ($result) use ($promise) { + $promise->resolve($result === 1); + }); + } + + return $promise->promise(); + } + + public function get($callback = null): PromiseInterface + { + $promise = new Deferred(); + + $this->acquire() + ->then(function ($result) use ($callback, $promise) { + if ($result) { + try { + $callback(); + } finally { + $promise->resolve($this->release()); + } + } + }); + + return $promise->promise(); + } + + public function release(): PromiseInterface + { + return $this->redis->eval(LuaScripts::releaseLock(), 1, $this->name, $this->owner); + } +} diff --git a/src/ChannelManagers/LocalChannelManager.php b/src/ChannelManagers/LocalChannelManager.php index 5144f26ab8..a95376306a 100644 --- a/src/ChannelManagers/LocalChannelManager.php +++ b/src/ChannelManagers/LocalChannelManager.php @@ -2,17 +2,18 @@ namespace BeyondCode\LaravelWebSockets\ChannelManagers; +use BeyondCode\LaravelWebSockets\Cache\ArrayLock; use BeyondCode\LaravelWebSockets\Channels\Channel; use BeyondCode\LaravelWebSockets\Channels\PresenceChannel; use BeyondCode\LaravelWebSockets\Channels\PrivateChannel; use BeyondCode\LaravelWebSockets\Contracts\ChannelManager; use BeyondCode\LaravelWebSockets\Helpers; use Carbon\Carbon; -use Illuminate\Cache\ArrayLock; use Illuminate\Cache\ArrayStore; use Illuminate\Support\Str; use Ratchet\ConnectionInterface; use React\EventLoop\LoopInterface; +use function React\Promise\all; use React\Promise\PromiseInterface; use stdClass; @@ -226,9 +227,7 @@ public function unsubscribeFromChannel(ConnectionInterface $connection, string $ { $channel = $this->findOrCreate($connection->app->id, $channelName); - return Helpers::createFulfilledPromise( - $channel->unsubscribe($connection, $payload) - ); + return $channel->unsubscribe($connection, $payload); } /** @@ -439,26 +438,24 @@ public function connectionPonged(ConnectionInterface $connection): PromiseInterf */ public function removeObsoleteConnections(): PromiseInterface { - $lock = $this->lock(); - try { - if (! $lock->acquire()) { - return Helpers::createFulfilledPromise(false); - } + return $this->lock()->get(function () { + return $this->getLocalConnections() + ->then(function ($connections) { + $promises = []; - $this->getLocalConnections()->then(function ($connections) { - foreach ($connections as $connection) { - $differenceInSeconds = $connection->lastPongedAt->diffInSeconds(Carbon::now()); + foreach ($connections as $connection) { + $differenceInSeconds = $connection->lastPongedAt->diffInSeconds(Carbon::now()); - if ($differenceInSeconds > 120) { - $this->unsubscribeFromAllChannels($connection); + if ($differenceInSeconds > 120) { + $promises[] = $this->unsubscribeFromAllChannels($connection); + } } - } - }); - return Helpers::createFulfilledPromise(true); - } finally { - optional($lock)->forceRelease(); - } + return all($promises); + })->then(function () { + $this->lock()->release(); + }); + }); } /** @@ -557,7 +554,7 @@ public function getServerId(): string /** * Get a new ArrayLock instance to avoid race conditions. * - * @return \Illuminate\Cache\CacheLock + * @return ArrayLock */ protected function lock() { diff --git a/src/ChannelManagers/RedisChannelManager.php b/src/ChannelManagers/RedisChannelManager.php index 6c87948730..04755f81c0 100644 --- a/src/ChannelManagers/RedisChannelManager.php +++ b/src/ChannelManagers/RedisChannelManager.php @@ -2,17 +2,19 @@ namespace BeyondCode\LaravelWebSockets\ChannelManagers; +use BeyondCode\LaravelWebSockets\Cache\RedisLock; +use BeyondCode\LaravelWebSockets\Channels\Channel; use BeyondCode\LaravelWebSockets\DashboardLogger; use BeyondCode\LaravelWebSockets\Helpers; use BeyondCode\LaravelWebSockets\Server\MockableConnection; use Carbon\Carbon; use Clue\React\Redis\Client; use Clue\React\Redis\Factory; -use Illuminate\Cache\RedisLock; use Illuminate\Support\Facades\Redis; use Illuminate\Support\Str; use Ratchet\ConnectionInterface; use React\EventLoop\LoopInterface; +use function React\Promise\all; use React\Promise\PromiseInterface; use stdClass; @@ -100,9 +102,12 @@ public function unsubscribeFromAllChannels(ConnectionInterface $connection): Pro { return $this->getGlobalChannels($connection->app->id) ->then(function ($channels) use ($connection) { + $promises = []; foreach ($channels as $channel) { - $this->unsubscribeFromChannel($connection, $channel, new stdClass); + $promises[] = $this->unsubscribeFromChannel($connection, $channel, new stdClass); } + + return all($promises); }) ->then(function () use ($connection) { return parent::unsubscribeFromAllChannels($connection); @@ -144,18 +149,36 @@ public function subscribeToChannel(ConnectionInterface $connection, string $chan */ public function unsubscribeFromChannel(ConnectionInterface $connection, string $channelName, stdClass $payload): PromiseInterface { - return parent::unsubscribeFromChannel($connection, $channelName, $payload) - ->then(function () use ($connection, $channelName) { - return $this->decrementSubscriptionsCount($connection->app->id, $channelName); - }) + return $this->getGlobalConnectionsCount($connection->app->id, $channelName) ->then(function ($count) use ($connection, $channelName) { - $this->removeConnectionFromSet($connection); - // If the total connections count gets to 0 after unsubscribe, - // try again to check & unsubscribe from the PubSub topic if needed. - if ($count < 1) { - $this->removeChannelFromSet($connection->app->id, $channelName); - $this->unsubscribeFromTopic($connection->app->id, $channelName); + if ($count === 0) { + // Make sure to not stay subscribed to the PubSub topic + // if there are no connections. + return $this->unsubscribeFromTopic($connection->app->id, $channelName); } + + return Helpers::createFulfilledPromise(null); + }) + ->then(function () use ($connection, $channelName) { + return $this->decrementSubscriptionsCount($connection->app->id, $channelName) + ->then(function ($count) use ($connection, $channelName) { + // If the total connections count gets to 0 after unsubscribe, + // try again to check & unsubscribe from the PubSub topic if needed. + if ($count < 1) { + $promises = []; + + $promises[] = $this->unsubscribeFromTopic($connection->app->id, $channelName); + $promises[] = $this->removeChannelFromSet($connection->app->id, $channelName); + + return all($promises); + } + }); + }) + ->then(function () use ($connection) { + return $this->removeConnectionFromSet($connection); + }) + ->then(function () use ($connection, $channelName, $payload) { + return parent::unsubscribeFromChannel($connection, $channelName, $payload); }); } @@ -371,23 +394,21 @@ public function connectionPonged(ConnectionInterface $connection): PromiseInterf */ public function removeObsoleteConnections(): PromiseInterface { - $lock = $this->lock(); - try { - $lock->get(function () { - $this->getConnectionsFromSet(0, now()->subMinutes(2)->format('U')) - ->then(function ($connections) { - foreach ($connections as $socketId => $appId) { - $connection = $this->fakeConnectionForApp($appId, $socketId); + return $this->lock()->get(function () { + return $this->getConnectionsFromSet(0, now()->subMinutes(2)->format('U')) + ->then(function ($connections) { + $promises = []; + foreach ($connections as $socketId => $appId) { + $connection = $this->fakeConnectionForApp($appId, $socketId); - $this->unsubscribeFromAllChannels($connection); - } - }); - }); + $promises[] = $this->unsubscribeFromAllChannels($connection); + } + return all($promises); + }); + })->then(function () { return parent::removeObsoleteConnections(); - } finally { - optional($lock)->forceRelease(); - } + }); } /** @@ -846,11 +867,11 @@ public function getRedisTopicName($appId, string $channel = null): string /** * Get a new RedisLock instance to avoid race conditions. * - * @return \Illuminate\Cache\CacheLock + * @return RedisLock */ protected function lock() { - return new RedisLock($this->redis, static::$lockName, 0); + return new RedisLock($this->publishClient, static::$lockName, 0); } /** diff --git a/src/Channels/Channel.php b/src/Channels/Channel.php index dbd874fd8e..d0c7447b42 100644 --- a/src/Channels/Channel.php +++ b/src/Channels/Channel.php @@ -6,9 +6,11 @@ use BeyondCode\LaravelWebSockets\DashboardLogger; use BeyondCode\LaravelWebSockets\Events\SubscribedToChannel; use BeyondCode\LaravelWebSockets\Events\UnsubscribedFromChannel; +use BeyondCode\LaravelWebSockets\Helpers; use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature; use Illuminate\Support\Str; use Ratchet\ConnectionInterface; +use React\Promise\PromiseInterface; use stdClass; class Channel @@ -116,12 +118,12 @@ public function subscribe(ConnectionInterface $connection, stdClass $payload): b * Unsubscribe connection from the channel. * * @param \Ratchet\ConnectionInterface $connection - * @return bool + * @return PromiseInterface */ - public function unsubscribe(ConnectionInterface $connection): bool + public function unsubscribe(ConnectionInterface $connection): PromiseInterface { if (! $this->hasConnection($connection)) { - return false; + return Helpers::createFulfilledPromise(false); } unset($this->connections[$connection->socketId]); @@ -132,7 +134,7 @@ public function unsubscribe(ConnectionInterface $connection): bool $this->getName() ); - return true; + return Helpers::createFulfilledPromise(true); } /** diff --git a/src/Channels/PresenceChannel.php b/src/Channels/PresenceChannel.php index 1d75b1f05d..51e015e902 100644 --- a/src/Channels/PresenceChannel.php +++ b/src/Channels/PresenceChannel.php @@ -5,8 +5,10 @@ use BeyondCode\LaravelWebSockets\DashboardLogger; use BeyondCode\LaravelWebSockets\Events\SubscribedToChannel; use BeyondCode\LaravelWebSockets\Events\UnsubscribedFromChannel; +use BeyondCode\LaravelWebSockets\Helpers; use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature; use Ratchet\ConnectionInterface; +use React\Promise\PromiseInterface; use stdClass; class PresenceChannel extends PrivateChannel @@ -100,30 +102,30 @@ public function subscribe(ConnectionInterface $connection, stdClass $payload): b * Unsubscribe connection from the channel. * * @param \Ratchet\ConnectionInterface $connection - * @return bool + * @return PromiseInterface */ - public function unsubscribe(ConnectionInterface $connection): bool + public function unsubscribe(ConnectionInterface $connection): PromiseInterface { $truth = parent::unsubscribe($connection); - $this->channelManager + return $this->channelManager ->getChannelMember($connection, $this->getName()) ->then(function ($user) { return @json_decode($user); }) ->then(function ($user) use ($connection) { if (! $user) { - return; + return Helpers::createFulfilledPromise(true); } - $this->channelManager + return $this->channelManager ->userLeftPresenceChannel($connection, $user, $this->getName()) ->then(function () use ($connection, $user) { // The `pusher_internal:member_removed` is triggered when a user leaves a channel. // It's quite possible that a user can have multiple connections to the same channel // (for example by having multiple browser tabs open) // and in this case the events will only be triggered when the last one is closed. - $this->channelManager + return $this->channelManager ->getMemberSockets($user->user_id, $connection->app->id, $this->getName()) ->then(function ($sockets) use ($connection, $user) { if (count($sockets) === 0) { @@ -149,8 +151,9 @@ public function unsubscribe(ConnectionInterface $connection): bool } }); }); + }) + ->then(function () use ($truth) { + return $truth; }); - - return $truth; } } diff --git a/src/Console/Commands/StartServer.php b/src/Console/Commands/StartServer.php index abe1d075a6..69ea1ffe47 100644 --- a/src/Console/Commands/StartServer.php +++ b/src/Console/Commands/StartServer.php @@ -12,6 +12,8 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Cache; use React\EventLoop\Factory as LoopFactory; +use React\EventLoop\LoopInterface; +use function React\Promise\all; class StartServer extends Command { @@ -69,6 +71,10 @@ public function __construct() */ public function handle() { + $this->laravel->singleton(LoopInterface::class, function () { + return $this->loop; + }); + $this->configureLoggers(); $this->configureManagers(); @@ -311,9 +317,13 @@ protected function triggerSoftShutdown() // be automatically be unsubscribed from all channels. $channelManager->getLocalConnections() ->then(function ($connections) { - foreach ($connections as $connection) { - $connection->close(); - } + return all(collect($connections)->map(function ($connection) { + return app('websockets.handler') + ->onClose($connection) + ->then(function () use ($connection) { + $connection->close(); + }); + })->toArray()); }) ->then(function () { $this->loop->stop(); diff --git a/src/Contracts/AppManager.php b/src/Contracts/AppManager.php index 153eda8a8d..f16b691799 100644 --- a/src/Contracts/AppManager.php +++ b/src/Contracts/AppManager.php @@ -3,37 +3,46 @@ namespace BeyondCode\LaravelWebSockets\Contracts; use BeyondCode\LaravelWebSockets\Apps\App; +use React\Promise\PromiseInterface; interface AppManager { /** * Get all apps. * - * @return array[\BeyondCode\LaravelWebSockets\Apps\App] + * @return PromiseInterface */ - public function all(): array; + public function all(): PromiseInterface; /** * Get app by id. * * @param string|int $appId - * @return \BeyondCode\LaravelWebSockets\Apps\App|null + * @return PromiseInterface */ - public function findById($appId): ?App; + public function findById($appId): PromiseInterface; /** * Get app by app key. * * @param string $appKey - * @return \BeyondCode\LaravelWebSockets\Apps\App|null + * @return PromiseInterface */ - public function findByKey($appKey): ?App; + public function findByKey($appKey): PromiseInterface; /** * Get app by secret. * * @param string $appSecret - * @return \BeyondCode\LaravelWebSockets\Apps\App|null + * @return PromiseInterface */ - public function findBySecret($appSecret): ?App; + public function findBySecret($appSecret): PromiseInterface; + + /** + * Create a new app. + * + * @param $appData + * @return PromiseInterface + */ + public function createApp($appData): PromiseInterface; } diff --git a/src/Dashboard/Http/Controllers/AuthenticateDashboard.php b/src/Dashboard/Http/Controllers/AuthenticateDashboard.php index a25922f734..f1f21fb836 100644 --- a/src/Dashboard/Http/Controllers/AuthenticateDashboard.php +++ b/src/Dashboard/Http/Controllers/AuthenticateDashboard.php @@ -4,8 +4,10 @@ use BeyondCode\LaravelWebSockets\Apps\App; use BeyondCode\LaravelWebSockets\Concerns\PushesToPusher; +use function Clue\React\Block\await; use Illuminate\Broadcasting\Broadcasters\PusherBroadcaster; use Illuminate\Http\Request; +use React\EventLoop\LoopInterface; class AuthenticateDashboard { @@ -21,7 +23,7 @@ class AuthenticateDashboard */ public function __invoke(Request $request) { - $app = App::findById($request->header('X-App-Id')); + $app = await(App::findById($request->header('X-App-Id')), app(LoopInterface::class)); $broadcaster = $this->getPusherBroadcaster([ 'key' => $app->key, diff --git a/src/Dashboard/Http/Controllers/ShowApps.php b/src/Dashboard/Http/Controllers/ShowApps.php new file mode 100644 index 0000000000..38724d7d40 --- /dev/null +++ b/src/Dashboard/Http/Controllers/ShowApps.php @@ -0,0 +1,26 @@ + await($apps->all(), app(LoopInterface::class), 2.0), + 'port' => config('websockets.dashboard.port', 6001), + ]); + } +} diff --git a/src/Dashboard/Http/Controllers/ShowDashboard.php b/src/Dashboard/Http/Controllers/ShowDashboard.php index eabd22d7c9..b2921bd6d7 100644 --- a/src/Dashboard/Http/Controllers/ShowDashboard.php +++ b/src/Dashboard/Http/Controllers/ShowDashboard.php @@ -4,7 +4,9 @@ use BeyondCode\LaravelWebSockets\Contracts\AppManager; use BeyondCode\LaravelWebSockets\DashboardLogger; +use function Clue\React\Block\await; use Illuminate\Http\Request; +use React\EventLoop\LoopInterface; class ShowDashboard { @@ -18,7 +20,7 @@ class ShowDashboard public function __invoke(Request $request, AppManager $apps) { return view('websockets::dashboard', [ - 'apps' => $apps->all(), + 'apps' => await($apps->all(), app(LoopInterface::class), 2.0), 'port' => config('websockets.dashboard.port', 6001), 'channels' => DashboardLogger::$channels, 'logPrefix' => DashboardLogger::LOG_CHANNEL_PREFIX, diff --git a/src/Dashboard/Http/Controllers/StoreApp.php b/src/Dashboard/Http/Controllers/StoreApp.php new file mode 100644 index 0000000000..04717f7668 --- /dev/null +++ b/src/Dashboard/Http/Controllers/StoreApp.php @@ -0,0 +1,36 @@ + (string) Str::uuid(), + 'key' => (string) Str::uuid(), + 'secret' => (string) Str::uuid(), + 'name' => $request->get('name'), + 'enable_client_messages' => $request->has('enable_client_messages'), + 'enable_statistics' => $request->has('enable_statistics'), + 'allowed_origins' => $request->get('allowed_origins'), + ]; + + await($apps->createApp($appData), app(LoopInterface::class)); + + return redirect()->route('laravel-websockets.apps'); + } +} diff --git a/src/Dashboard/Http/Requests/StoreAppRequest.php b/src/Dashboard/Http/Requests/StoreAppRequest.php new file mode 100644 index 0000000000..1910850080 --- /dev/null +++ b/src/Dashboard/Http/Requests/StoreAppRequest.php @@ -0,0 +1,20 @@ + 'required', + ]; + } +} diff --git a/src/Rules/AppId.php b/src/Rules/AppId.php index ce5ea2eae5..db92052735 100644 --- a/src/Rules/AppId.php +++ b/src/Rules/AppId.php @@ -3,7 +3,9 @@ namespace BeyondCode\LaravelWebSockets\Rules; use BeyondCode\LaravelWebSockets\Contracts\AppManager; +use function Clue\React\Block\await; use Illuminate\Contracts\Validation\Rule; +use React\EventLoop\Factory; class AppId implements Rule { @@ -18,7 +20,7 @@ public function passes($attribute, $value) { $manager = app(AppManager::class); - return $manager->findById($value) ? true : false; + return await($manager->findById($value), Factory::create()) ? true : false; } /** diff --git a/src/Server/Router.php b/src/Server/Router.php index 3092f7cf2e..5be0fe0f2b 100644 --- a/src/Server/Router.php +++ b/src/Server/Router.php @@ -70,7 +70,7 @@ public function getCustomRoutes(): array */ public function registerRoutes() { - $this->get('/app/{appKey}', config('websockets.handlers.websocket')); + $this->get('/app/{appKey}', 'websockets.handler'); $this->post('/apps/{appId}/events', config('websockets.handlers.trigger_event')); $this->get('/apps/{appId}/channels', config('websockets.handlers.fetch_channels')); $this->get('/apps/{appId}/channels/{channelName}', config('websockets.handlers.fetch_channel')); @@ -191,9 +191,10 @@ public function registerCustomRoutes() */ protected function getRoute(string $method, string $uri, $action): Route { + $action = app($action); $action = is_subclass_of($action, MessageComponentInterface::class) ? $this->createWebSocketsServer($action) - : app($action); + : $action; return new Route($uri, ['_controller' => $action], [], [], null, [], [$method]); } @@ -201,13 +202,11 @@ protected function getRoute(string $method, string $uri, $action): Route /** * Create a new websockets server to handle the action. * - * @param string $action + * @param MessageComponentInterface $app * @return \Ratchet\WebSocket\WsServer */ - protected function createWebSocketsServer(string $action): WsServer + protected function createWebSocketsServer($app): WsServer { - $app = app($action); - if (WebsocketsLogger::isEnabled()) { $app = WebsocketsLogger::decorate($app); } diff --git a/src/Server/WebSocketHandler.php b/src/Server/WebSocketHandler.php index 855532dd3f..a5bff133a5 100644 --- a/src/Server/WebSocketHandler.php +++ b/src/Server/WebSocketHandler.php @@ -9,10 +9,14 @@ use BeyondCode\LaravelWebSockets\Events\NewConnection; use BeyondCode\LaravelWebSockets\Events\WebSocketMessageReceived; use BeyondCode\LaravelWebSockets\Facades\StatisticsCollector; +use BeyondCode\LaravelWebSockets\Helpers; +use BeyondCode\LaravelWebSockets\Server\Exceptions\WebSocketException; use Exception; use Ratchet\ConnectionInterface; use Ratchet\RFC6455\Messaging\MessageInterface; use Ratchet\WebSocket\MessageComponentInterface; +use React\Promise\Deferred; +use React\Promise\PromiseInterface; class WebSocketHandler implements MessageComponentInterface { @@ -47,30 +51,38 @@ public function onOpen(ConnectionInterface $connection) } $this->verifyAppKey($connection) - ->verifyOrigin($connection) - ->limitConcurrentConnections($connection) - ->generateSocketId($connection) - ->establishConnection($connection); + ->then(function () use ($connection) { + try { + $this->verifyOrigin($connection) + ->limitConcurrentConnections($connection) + ->generateSocketId($connection) + ->establishConnection($connection); - if (isset($connection->app)) { - /** @var \GuzzleHttp\Psr7\Request $request */ - $request = $connection->httpRequest; + if (isset($connection->app)) { + /** @var \GuzzleHttp\Psr7\Request $request */ + $request = $connection->httpRequest; - if ($connection->app->statisticsEnabled) { - StatisticsCollector::connection($connection->app->id); - } + if ($connection->app->statisticsEnabled) { + StatisticsCollector::connection($connection->app->id); + } - $this->channelManager->subscribeToApp($connection->app->id); + $this->channelManager->subscribeToApp($connection->app->id); - $this->channelManager->connectionPonged($connection); + $this->channelManager->connectionPonged($connection); - DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_CONNECTED, [ - 'origin' => "{$request->getUri()->getScheme()}://{$request->getUri()->getHost()}", - 'socketId' => $connection->socketId, - ]); + DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_CONNECTED, [ + 'origin' => "{$request->getUri()->getScheme()}://{$request->getUri()->getHost()}", + 'socketId' => $connection->socketId, + ]); - NewConnection::dispatch($connection->app->id, $connection->socketId); - } + NewConnection::dispatch($connection->app->id, $connection->socketId); + } + } catch (WebSocketException $exception) { + $this->onError($connection, $exception); + } + }, function ($exception) use ($connection) { + $this->onError($connection, $exception); + }); } /** @@ -105,11 +117,11 @@ public function onMessage(ConnectionInterface $connection, MessageInterface $mes * Handle the websocket close. * * @param \Ratchet\ConnectionInterface $connection - * @return void + * @return PromiseInterface */ public function onClose(ConnectionInterface $connection) { - $this->channelManager + return $this->channelManager ->unsubscribeFromAllChannels($connection) ->then(function (bool $unsubscribed) use ($connection) { if (isset($connection->app)) { @@ -117,8 +129,13 @@ public function onClose(ConnectionInterface $connection) StatisticsCollector::disconnection($connection->app->id); } - $this->channelManager->unsubscribeFromApp($connection->app->id); + return $this->channelManager->unsubscribeFromApp($connection->app->id); + } + return Helpers::createFulfilledPromise(true); + }) + ->then(function () use ($connection) { + if (isset($connection->app)) { DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_DISCONNECTED, [ 'socketId' => $connection->socketId, ]); @@ -160,21 +177,28 @@ protected function connectionCanBeMade(ConnectionInterface $connection): bool * Verify the app key validity. * * @param \Ratchet\ConnectionInterface $connection - * @return $this + * @return PromiseInterface */ - protected function verifyAppKey(ConnectionInterface $connection) + protected function verifyAppKey(ConnectionInterface $connection): PromiseInterface { + $deferred = new Deferred(); + $query = QueryParameters::create($connection->httpRequest); $appKey = $query->get('appKey'); - if (! $app = App::findByKey($appKey)) { - throw new Exceptions\UnknownAppKey($appKey); - } + App::findByKey($appKey) + ->then(function ($app) use ($appKey, $connection, $deferred) { + if (! $app) { + $deferred->reject(new Exceptions\UnknownAppKey($appKey)); + } - $connection->app = $app; + $connection->app = $app; - return $this; + $deferred->resolve(); + }); + + return $deferred->promise(); } /** diff --git a/src/Statistics/Collectors/MemoryCollector.php b/src/Statistics/Collectors/MemoryCollector.php index 8de17aa19e..908120c0a0 100644 --- a/src/Statistics/Collectors/MemoryCollector.php +++ b/src/Statistics/Collectors/MemoryCollector.php @@ -92,25 +92,27 @@ public function save() { $this->getStatistics()->then(function ($statistics) { foreach ($statistics as $appId => $statistic) { - if (! $statistic->isEnabled()) { - continue; - } - - if ($statistic->shouldHaveTracesRemoved()) { - $this->resetAppTraces($appId); - - continue; - } - - $this->createRecord($statistic, $appId); - - $this->channelManager - ->getGlobalConnectionsCount($appId) - ->then(function ($connections) use ($statistic) { - $statistic->reset( - is_null($connections) ? 0 : $connections - ); - }); + $statistic->isEnabled()->then(function ($isEnabled) use ($appId, $statistic) { + if (! $isEnabled) { + return; + } + + if ($statistic->shouldHaveTracesRemoved()) { + $this->resetAppTraces($appId); + + return; + } + + $this->createRecord($statistic, $appId); + + $this->channelManager + ->getGlobalConnectionsCount($appId) + ->then(function ($connections) use ($statistic) { + $statistic->reset( + is_null($connections) ? 0 : $connections + ); + }); + }); } }); } diff --git a/src/Statistics/Statistic.php b/src/Statistics/Statistic.php index 8de67c2a2e..5af761c455 100644 --- a/src/Statistics/Statistic.php +++ b/src/Statistics/Statistic.php @@ -3,6 +3,8 @@ namespace BeyondCode\LaravelWebSockets\Statistics; use BeyondCode\LaravelWebSockets\Apps\App; +use React\Promise\Deferred; +use React\Promise\PromiseInterface; class Statistic { @@ -118,11 +120,17 @@ public function setApiMessagesCount(int $apiMessagesCount) /** * Check if the app has statistics enabled. * - * @return bool + * @return PromiseInterface */ - public function isEnabled(): bool + public function isEnabled(): PromiseInterface { - return App::findById($this->appId)->statisticsEnabled; + $deferred = new Deferred(); + + App::findById($this->appId)->then(function ($app) use ($deferred) { + $deferred->resolve($app->statisticsEnabled); + }); + + return $deferred->promise(); } /** diff --git a/src/WebSocketsServiceProvider.php b/src/WebSocketsServiceProvider.php index 829943e600..06f7a6cc71 100644 --- a/src/WebSocketsServiceProvider.php +++ b/src/WebSocketsServiceProvider.php @@ -6,15 +6,25 @@ use BeyondCode\LaravelWebSockets\Contracts\StatisticsStore; use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\AuthenticateDashboard; use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\SendMessage; +use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\ShowApps; use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\ShowDashboard; use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\ShowStatistics; +use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\StoreApp; use BeyondCode\LaravelWebSockets\Dashboard\Http\Middleware\Authorize as AuthorizeDashboard; use BeyondCode\LaravelWebSockets\Queue\AsyncRedisConnector; use BeyondCode\LaravelWebSockets\Server\Router; +use Clue\React\SQLite\DatabaseInterface; +use Clue\React\SQLite\Factory as SQLiteFactory; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use React\EventLoop\Factory; +use React\EventLoop\LoopInterface; +use React\MySQL\ConnectionInterface; +use React\MySQL\Factory as MySQLFactory; +use SplFileInfo; +use Symfony\Component\Finder\Finder; class WebSocketsServiceProvider extends ServiceProvider { @@ -38,8 +48,16 @@ public function boot() __DIR__.'/../database/migrations/0000_00_00_000000_rename_statistics_counters.php' => database_path('migrations/0000_00_00_000000_rename_statistics_counters.php'), ], 'migrations'); + $this->registerEventLoop(); + + $this->registerSQLiteDatabase(); + + $this->registerMySqlDatabase(); + $this->registerAsyncRedisQueueDriver(); + $this->registerWebSocketHandler(); + $this->registerRouter(); $this->registerManagers(); @@ -61,6 +79,13 @@ public function register() // } + protected function registerEventLoop() + { + $this->app->singleton(LoopInterface::class, function () { + return Factory::create(); + }); + } + /** * Register the async, non-blocking Redis queue driver. * @@ -73,6 +98,47 @@ protected function registerAsyncRedisQueueDriver() }); } + protected function registerSQLiteDatabase() + { + $this->app->singleton(DatabaseInterface::class, function () { + $factory = new SQLiteFactory($this->app->make(LoopInterface::class)); + + $database = $factory->openLazy( + config('websockets.managers.sqlite.database', ':memory:') + ); + + $migrations = (new Finder()) + ->files() + ->ignoreDotFiles(true) + ->in(__DIR__.'/../database/migrations/sqlite') + ->name('*.sql'); + + /** @var SplFileInfo $migration */ + foreach ($migrations as $migration) { + $database->exec($migration->getContents()); + } + + return $database; + }); + } + + protected function registerMySqlDatabase() + { + $this->app->singleton(ConnectionInterface::class, function () { + $factory = new MySQLFactory($this->app->make(LoopInterface::class)); + + $connectionKey = 'database.connections.'.config('websockets.managers.mysql.connection'); + + $auth = trim(config($connectionKey.'.username').':'.config($connectionKey.'.password'), ':'); + $connection = trim(config($connectionKey.'.host').':'.config($connectionKey.'.port'), ':'); + $database = config($connectionKey.'.database'); + + $database = $factory->createLazyConnection(trim("{$auth}@{$connection}/{$database}", '@')); + + return $database; + }); + } + /** * Register the statistics-related contracts. * @@ -98,7 +164,7 @@ protected function registerStatistics() } /** - * Regsiter the dashboard components. + * Register the dashboard components. * * @return void */ @@ -165,6 +231,8 @@ protected function registerDashboardRoutes() 'middleware' => config('websockets.dashboard.middleware', [AuthorizeDashboard::class]), ], function () { Route::get('/', ShowDashboard::class)->name('dashboard'); + Route::get('/apps', ShowApps::class)->name('apps'); + Route::post('/apps', StoreApp::class)->name('apps.store'); Route::get('/api/{appId}/statistics', ShowStatistics::class)->name('statistics'); Route::post('/auth', AuthenticateDashboard::class)->name('auth'); Route::post('/event', SendMessage::class)->name('event'); @@ -182,4 +250,11 @@ protected function registerDashboardGate() return $this->app->environment('local'); }); } + + protected function registerWebSocketHandler() + { + $this->app->singleton('websockets.handler', function () { + return app(config('websockets.handlers.websocket')); + }); + } } diff --git a/tests/Apps/ConfigAppManagerTest.php b/tests/Apps/ConfigAppManagerTest.php new file mode 100644 index 0000000000..ce0454aa8c --- /dev/null +++ b/tests/Apps/ConfigAppManagerTest.php @@ -0,0 +1,98 @@ +set('websockets.managers.app', ConfigAppManager::class); + $app['config']->set('websockets.apps', []); + } + + public function setUp(): void + { + parent::setUp(); + + $this->apps = app()->make(AppManager::class); + } + + public function test_can_return_all_apps() + { + $apps = $this->await($this->apps->all()); + $this->assertCount(0, $apps); + + $this->await($this->apps->createApp([ + 'id' => 1, + 'key' => 'test', + 'secret' => 'secret', + 'name' => 'Test', + 'enable_client_messages' => true, + 'enable_statistics' => false, + ])); + + $apps = $this->await($this->apps->all()); + $this->assertCount(1, $apps); + } + + public function test_can_find_apps_by_id() + { + $this->await($this->apps->createApp([ + 'id' => 1, + 'key' => 'test', + 'secret' => 'secret', + 'name' => 'Test', + 'enable_client_messages' => true, + 'enable_statistics' => false, + ])); + + $app = $this->await($this->apps->findById(1)); + + $this->assertInstanceOf(App::class, $app); + $this->assertSame('test', $app->key); + } + + public function test_can_find_apps_by_key() + { + $this->await($this->apps->createApp([ + 'id' => 1, + 'key' => 'key', + 'secret' => 'secret', + 'name' => 'Test', + 'enable_client_messages' => true, + 'enable_statistics' => false, + ])); + + $app = $this->await($this->apps->findByKey('key')); + + $this->assertInstanceOf(App::class, $app); + $this->assertSame('key', $app->key); + } + + public function test_can_find_apps_by_secret() + { + $this->await($this->apps->createApp([ + 'id' => 1, + 'key' => 'key', + 'secret' => 'secret', + 'name' => 'Test', + 'enable_client_messages' => true, + 'enable_statistics' => false, + ])); + + $app = $this->await($this->apps->findBySecret('secret')); + + $this->assertInstanceOf(App::class, $app); + $this->assertSame('key', $app->key); + } +} diff --git a/tests/Apps/MysqlAppManagerTest.php b/tests/Apps/MysqlAppManagerTest.php new file mode 100644 index 0000000000..3c1b3a470c --- /dev/null +++ b/tests/Apps/MysqlAppManagerTest.php @@ -0,0 +1,110 @@ +set('websockets.managers.app', MysqlAppManager::class); + $app['config']->set('database.connections.mysql.database', 'websockets_test'); + $app['config']->set('database.connections.mysql.username', 'root'); + $app['config']->set('database.connections.mysql.password', 'password'); + + $app['config']->set('websockets.managers.mysql.table', 'websockets_apps'); + $app['config']->set('websockets.managers.mysql.connection', 'mysql'); + $app['config']->set('database.connections.default', 'mysql'); + } + + public function setUp(): void + { + parent::setUp(); + + $this->artisan('migrate:fresh', [ + '--database' => 'mysql', + '--realpath' => true, + '--path' => __DIR__.'/../../database/migrations/', + ]); + + $this->apps = app()->make(AppManager::class); + } + + public function test_can_return_all_apps() + { + $apps = $this->await($this->apps->all()); + $this->assertCount(0, $apps); + + $this->await($this->apps->createApp([ + 'id' => 1, + 'key' => 'test', + 'secret' => 'secret', + 'name' => 'Test', + 'enable_client_messages' => true, + 'enable_statistics' => false, + ])); + + $apps = $this->await($this->apps->all()); + $this->assertCount(1, $apps); + } + + public function test_can_find_apps_by_id() + { + $this->await($this->apps->createApp([ + 'id' => 1, + 'key' => 'test', + 'secret' => 'secret', + 'name' => 'Test', + 'enable_client_messages' => true, + 'enable_statistics' => false, + ])); + + $app = $this->await($this->apps->findById(1)); + + $this->assertInstanceOf(App::class, $app); + $this->assertSame('test', $app->key); + } + + public function test_can_find_apps_by_key() + { + $this->await($this->apps->createApp([ + 'id' => 1, + 'key' => 'key', + 'secret' => 'secret', + 'name' => 'Test', + 'enable_client_messages' => true, + 'enable_statistics' => false, + ])); + + $app = $this->await($this->apps->findByKey('key')); + + $this->assertInstanceOf(App::class, $app); + $this->assertSame('key', $app->key); + } + + public function test_can_find_apps_by_secret() + { + $this->await($this->apps->createApp([ + 'id' => 1, + 'key' => 'key', + 'secret' => 'secret', + 'name' => 'Test', + 'enable_client_messages' => true, + 'enable_statistics' => false, + ])); + + $app = $this->await($this->apps->findBySecret('secret')); + + $this->assertInstanceOf(App::class, $app); + $this->assertSame('key', $app->key); + } +} diff --git a/tests/Apps/SqliteAppManagerTest.php b/tests/Apps/SqliteAppManagerTest.php new file mode 100644 index 0000000000..b4250690bc --- /dev/null +++ b/tests/Apps/SqliteAppManagerTest.php @@ -0,0 +1,97 @@ +set('websockets.managers.app', SQLiteAppManager::class); + } + + public function setUp(): void + { + parent::setUp(); + + $this->apps = app()->make(AppManager::class); + } + + public function test_can_return_all_apps() + { + $apps = $this->await($this->apps->all()); + $this->assertCount(0, $apps); + + $this->await($this->apps->createApp([ + 'id' => 1, + 'key' => 'test', + 'secret' => 'secret', + 'name' => 'Test', + 'enable_client_messages' => true, + 'enable_statistics' => false, + ])); + + $apps = $this->await($this->apps->all()); + $this->assertCount(1, $apps); + } + + public function test_can_find_apps_by_id() + { + $this->await($this->apps->createApp([ + 'id' => 1, + 'key' => 'test', + 'secret' => 'secret', + 'name' => 'Test', + 'enable_client_messages' => true, + 'enable_statistics' => false, + ])); + + $app = $this->await($this->apps->findById(1)); + + $this->assertInstanceOf(App::class, $app); + $this->assertSame('test', $app->key); + } + + public function test_can_find_apps_by_key() + { + $this->await($this->apps->createApp([ + 'id' => 1, + 'key' => 'key', + 'secret' => 'secret', + 'name' => 'Test', + 'enable_client_messages' => true, + 'enable_statistics' => false, + ])); + + $app = $this->await($this->apps->findByKey('key')); + + $this->assertInstanceOf(App::class, $app); + $this->assertSame('key', $app->key); + } + + public function test_can_find_apps_by_secret() + { + $this->await($this->apps->createApp([ + 'id' => 1, + 'key' => 'key', + 'secret' => 'secret', + 'name' => 'Test', + 'enable_client_messages' => true, + 'enable_statistics' => false, + ])); + + $app = $this->await($this->apps->findBySecret('secret')); + + $this->assertInstanceOf(App::class, $app); + $this->assertSame('key', $app->key); + } +} diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index 2e4f2ed0d2..a871cf4443 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -2,43 +2,43 @@ namespace BeyondCode\LaravelWebSockets\Test; -use BeyondCode\LaravelWebSockets\Server\Exceptions\OriginNotAllowed; use BeyondCode\LaravelWebSockets\Server\Exceptions\UnknownAppKey; class ConnectionTest extends TestCase { public function test_cannot_connect_with_a_wrong_app_key() { - $this->expectException(UnknownAppKey::class); + $this->startServer(); - $this->newActiveConnection(['public-channel'], 'NonWorkingKey'); + $response = $this->await($this->joinWebSocketServer(['public-channel'], 'NonWorkingKey')); + $this->assertSame('{"event":"pusher:error","data":{"message":"Could not find app key `NonWorkingKey`.","code":4001}}', (string) $response); } public function test_unconnected_app_cannot_store_statistics() { - $this->expectException(UnknownAppKey::class); + $this->startServer(); - $this->newActiveConnection(['public-channel'], 'NonWorkingKey'); + $response = $this->await($this->joinWebSocketServer(['public-channel'], 'NonWorkingKey')); + $this->assertSame('{"event":"pusher:error","data":{"message":"Could not find app key `NonWorkingKey`.","code":4001}}', (string) $response); - $this->assertCount(0, $this->statisticsCollector->getStatistics()); + $count = $this->await($this->statisticsCollector->getStatistics()); + $this->assertCount(0, $count); } public function test_origin_validation_should_fail_for_no_origin() { - $this->expectException(OriginNotAllowed::class); + $this->startServer(); - $connection = $this->newConnection('TestOrigin'); - - $this->pusherServer->onOpen($connection); + $response = $this->await($this->joinWebSocketServer(['public-channel'], 'TestOrigin')); + $this->assertSame('{"event":"pusher:error","data":{"message":"The origin is not allowed for `TestOrigin`.","code":4009}}', (string) $response); } public function test_origin_validation_should_fail_for_wrong_origin() { - $this->expectException(OriginNotAllowed::class); + $this->startServer(); - $connection = $this->newConnection('TestOrigin', ['Origin' => 'https://google.ro']); - - $this->pusherServer->onOpen($connection); + $response = $this->await($this->joinWebSocketServer(['public-channel'], 'TestOrigin', ['Origin' => 'https://google.ro'])); + $this->assertSame('{"event":"pusher:error","data":{"message":"The origin is not allowed for `TestOrigin`.","code":4009}}', (string) $response); } public function test_origin_validation_should_pass_for_the_right_origin() diff --git a/tests/Dashboard/AppsTest.php b/tests/Dashboard/AppsTest.php new file mode 100644 index 0000000000..de42be360a --- /dev/null +++ b/tests/Dashboard/AppsTest.php @@ -0,0 +1,38 @@ +set('websockets.managers.app', SQLiteAppManager::class); + } + + public function test_can_list_all_apps() + { + $this->actingAs(factory(User::class)->create()) + ->get(route('laravel-websockets.apps')) + ->assertViewHas('apps', []); + } + + public function test_can_create_app() + { + $this->actingAs(factory(User::class)->create()) + ->post(route('laravel-websockets.apps.store', [ + 'name' => 'New App', + ])); + + $this->actingAs(factory(User::class)->create()) + ->get(route('laravel-websockets.apps')) + ->assertViewHas('apps', function ($apps) { + return count($apps) === 1 && $apps[0]['name'] === 'New App'; + }); + } +} diff --git a/tests/Dashboard/DashboardTest.php b/tests/Dashboard/DashboardTest.php index d25d1e0196..f4c7bf5acd 100644 --- a/tests/Dashboard/DashboardTest.php +++ b/tests/Dashboard/DashboardTest.php @@ -17,7 +17,6 @@ public function test_can_see_dashboard() { $this->actingAs(factory(User::class)->create()) ->get(route('laravel-websockets.dashboard')) - ->assertResponseOk() - ->see('WebSockets Dashboard'); + ->assertResponseOk(); } } diff --git a/tests/FetchChannelTest.php b/tests/FetchChannelTest.php index 53300ccd83..e4612bc91f 100644 --- a/tests/FetchChannelTest.php +++ b/tests/FetchChannelTest.php @@ -5,33 +5,26 @@ use BeyondCode\LaravelWebSockets\API\FetchChannel; use GuzzleHttp\Psr7\Request; use Illuminate\Http\JsonResponse; -use Symfony\Component\HttpKernel\Exception\HttpException; +use Pusher\Pusher; class FetchChannelTest extends TestCase { public function test_invalid_signatures_can_not_access_the_api() { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Invalid auth signature provided.'); + $this->startServer(); - $connection = new Mocks\Connection; - - $requestPath = '/apps/1234/channel/my-channel'; + $requestPath = '/apps/1234/channels/my-channel'; - $routeParams = [ - 'appId' => '1234', - 'channelName' => 'my-channel', - ]; - - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'InvalidSecret', 'GET', $requestPath - ); + )); - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + $request = new Request('GET', "{$requestPath}?{$queryString}"); - $controller = app(FetchChannel::class); + $response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}")); - $controller->onOpen($connection, $request); + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame('{"error":"Invalid auth signature provided."}', $response->getBody()->getContents()); } public function test_it_returns_the_channel_information() @@ -47,7 +40,7 @@ public function test_it_returns_the_channel_information() 'channelName' => 'my-channel', ]; - $queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); + $queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath)); $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); @@ -78,7 +71,7 @@ public function test_it_returns_presence_channel_information() 'channelName' => 'presence-channel', ]; - $queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); + $queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath)); $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); @@ -100,26 +93,17 @@ public function test_it_returns_404_for_invalid_channels() { $this->skipOnRedisReplication(); - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Unknown channel'); + $this->startServer(); $this->newActiveConnection(['my-channel']); - $connection = new Mocks\Connection; - - $requestPath = '/apps/1234/channel/invalid-channel'; - - $routeParams = [ - 'appId' => '1234', - 'channelName' => 'invalid-channel', - ]; + $requestPath = '/apps/1234/channels/invalid-channel'; - $queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); + $queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath)); - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + $response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}")); - $controller = app(FetchChannel::class); - - $controller->onOpen($connection, $request); + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame('{"error":"Unknown channel `invalid-channel`."}', $response->getBody()->getContents()); } } diff --git a/tests/FetchChannelsTest.php b/tests/FetchChannelsTest.php index ff5e3f9d5e..049f27c0aa 100644 --- a/tests/FetchChannelsTest.php +++ b/tests/FetchChannelsTest.php @@ -5,32 +5,26 @@ use BeyondCode\LaravelWebSockets\API\FetchChannels; use GuzzleHttp\Psr7\Request; use Illuminate\Http\JsonResponse; -use Symfony\Component\HttpKernel\Exception\HttpException; +use Pusher\Pusher; class FetchChannelsTest extends TestCase { public function test_invalid_signatures_can_not_access_the_api() { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Invalid auth signature provided.'); - - $connection = new Mocks\Connection; + $this->startServer(); $requestPath = '/apps/1234/channels'; - $routeParams = [ - 'appId' => '1234', - ]; - - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'InvalidSecret', 'GET', $requestPath - ); + )); - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + $request = new Request('GET', "{$requestPath}?{$queryString}"); - $controller = app(FetchChannels::class); + $response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}")); - $controller->onOpen($connection, $request); + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame('{"error":"Invalid auth signature provided."}', $response->getBody()->getContents()); } public function test_it_returns_the_channel_information() @@ -45,9 +39,9 @@ public function test_it_returns_the_channel_information() 'appId' => '1234', ]; - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'TestSecret', 'GET', $requestPath - ); + )); $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); @@ -80,9 +74,9 @@ public function test_it_returns_the_channel_information_for_prefix() 'appId' => '1234', ]; - $queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath, [ + $queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath, [ 'filter_by_prefix' => 'presence-global', - ]); + ])); $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); @@ -116,10 +110,10 @@ public function test_it_returns_the_channel_information_for_prefix_with_user_cou 'appId' => '1234', ]; - $queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath, [ + $queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath, [ 'filter_by_prefix' => 'presence-global', 'info' => 'user_count', - ]); + ])); $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); @@ -144,29 +138,18 @@ public function test_it_returns_the_channel_information_for_prefix_with_user_cou public function test_can_not_get_non_presence_channel_user_count() { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Request must be limited to presence channels in order to fetch user_count'); - - $connection = new Mocks\Connection; + $this->startServer(); $requestPath = '/apps/1234/channels'; - $routeParams = [ - 'appId' => '1234', - ]; - - $queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath, [ + $queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath, [ 'info' => 'user_count', - ]); + ])); - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + $response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}")); - $controller = app(FetchChannels::class); - - $controller->onOpen($connection, $request); - - /** @var JsonResponse $response */ - $response = array_pop($connection->sentRawData); + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame('{"error":"Request must be limited to presence channels in order to fetch user_count"}', $response->getBody()->getContents()); } public function test_it_returns_empty_object_for_no_channels_found() @@ -179,7 +162,7 @@ public function test_it_returns_empty_object_for_no_channels_found() 'appId' => '1234', ]; - $queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); + $queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath)); $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); diff --git a/tests/FetchUsersTest.php b/tests/FetchUsersTest.php index a0b664f0bb..f78bad06b4 100644 --- a/tests/FetchUsersTest.php +++ b/tests/FetchUsersTest.php @@ -4,87 +4,56 @@ use BeyondCode\LaravelWebSockets\API\FetchUsers; use GuzzleHttp\Psr7\Request; -use Symfony\Component\HttpKernel\Exception\HttpException; +use Pusher\Pusher; class FetchUsersTest extends TestCase { public function test_invalid_signatures_can_not_access_the_api() { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Invalid auth signature provided.'); + $this->startServer(); - $connection = new Mocks\Connection; - - $requestPath = '/apps/1234/channel/my-channel'; - - $routeParams = [ - 'appId' => '1234', - 'channelName' => 'my-channel', - ]; + $requestPath = '/apps/1234/channels/my-channel/users'; - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'InvalidSecret', 'GET', $requestPath - ); - - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + )); - $controller = app(FetchUsers::class); + $response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}")); - $controller->onOpen($connection, $request); + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame('{"error":"Invalid auth signature provided."}', $response->getBody()->getContents()); } public function test_it_only_returns_data_for_presence_channels() { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Invalid presence channel'); - - $this->newActiveConnection(['my-channel']); - - $connection = new Mocks\Connection; - - $requestPath = '/apps/1234/channel/my-channel/users'; + $this->startServer(); - $routeParams = [ - 'appId' => '1234', - 'channelName' => 'my-channel', - ]; + $requestPath = '/apps/1234/channels/my-channel/users'; - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'TestSecret', 'GET', $requestPath - ); - - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + )); - $controller = app(FetchUsers::class); + $response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}")); - $controller->onOpen($connection, $request); + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame('{"error":"Invalid presence channel `my-channel`"}', $response->getBody()->getContents()); } - public function test_it_returns_404_for_invalid_channels() + public function test_it_returns_400_for_invalid_channels() { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Invalid presence channel'); - - $this->newActiveConnection(['my-channel']); - - $connection = new Mocks\Connection; + $this->startServer(); - $requestPath = '/apps/1234/channel/invalid-channel/users'; + $requestPath = '/apps/1234/channels/invalid-channel/users'; - $routeParams = [ - 'appId' => '1234', - 'channelName' => 'invalid-channel', - ]; - - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'TestSecret', 'GET', $requestPath - ); + )); - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - - $controller = app(FetchUsers::class); + $response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}")); - $controller->onOpen($connection, $request); + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame('{"error":"Invalid presence channel `invalid-channel`"}', $response->getBody()->getContents()); } public function test_it_returns_connected_user_information() @@ -100,7 +69,7 @@ public function test_it_returns_connected_user_information() 'channelName' => 'presence-channel', ]; - $queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); + $queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath)); $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); @@ -130,7 +99,7 @@ public function test_multiple_clients_with_same_id_gets_counted_once() 'channelName' => 'presence-channel', ]; - $queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); + $queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath)); $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); diff --git a/tests/PresenceChannelTest.php b/tests/PresenceChannelTest.php index 499d319d40..3795d973d1 100644 --- a/tests/PresenceChannelTest.php +++ b/tests/PresenceChannelTest.php @@ -6,6 +6,7 @@ use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature; use GuzzleHttp\Psr7\Request; use Illuminate\Http\JsonResponse; +use Pusher\Pusher; use Ratchet\ConnectionInterface; class PresenceChannelTest extends TestCase @@ -418,13 +419,13 @@ public function test_it_fires_the_event_to_presence_channel() 'appId' => '1234', ]; - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'TestSecret', 'POST', $requestPath, [ 'name' => 'some-event', 'channels' => ['presence-channel'], 'data' => json_encode(['some-data' => 'yes']), ], - ); + )); $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); @@ -459,13 +460,13 @@ public function test_it_fires_event_across_servers_when_there_are_not_users_loca 'appId' => '1234', ]; - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'TestSecret', 'POST', $requestPath, [ 'name' => 'some-event', 'channels' => ['presence-channel'], 'data' => json_encode(['some-data' => 'yes']), ], - ); + )); $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); @@ -507,13 +508,13 @@ public function test_it_fires_event_across_servers_when_there_are_users_locally_ 'appId' => '1234', ]; - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'TestSecret', 'POST', $requestPath, [ 'name' => 'some-event', 'channels' => ['presence-channel'], 'data' => json_encode(['some-data' => 'yes']), ], - ); + )); $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); diff --git a/tests/PrivateChannelTest.php b/tests/PrivateChannelTest.php index e2fa3f8c4e..7ae2ba165f 100644 --- a/tests/PrivateChannelTest.php +++ b/tests/PrivateChannelTest.php @@ -6,6 +6,7 @@ use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature; use GuzzleHttp\Psr7\Request; use Illuminate\Http\JsonResponse; +use Pusher\Pusher; use Ratchet\ConnectionInterface; class PrivateChannelTest extends TestCase @@ -238,13 +239,13 @@ public function test_it_fires_the_event_to_private_channel() 'appId' => '1234', ]; - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'TestSecret', 'POST', $requestPath, [ 'name' => 'some-event', 'channels' => ['private-channel'], 'data' => json_encode(['some-data' => 'yes']), ], - ); + )); $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); @@ -279,13 +280,13 @@ public function test_it_fires_event_across_servers_when_there_are_not_users_loca 'appId' => '1234', ]; - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'TestSecret', 'POST', $requestPath, [ 'name' => 'some-event', 'channels' => ['private-channel'], 'data' => json_encode(['some-data' => 'yes']), ], - ); + )); $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); @@ -327,13 +328,13 @@ public function test_it_fires_event_across_servers_when_there_are_users_locally_ 'appId' => '1234', ]; - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'TestSecret', 'POST', $requestPath, [ 'name' => 'some-event', 'channels' => ['private-channel'], 'data' => json_encode(['some-data' => 'yes']), ], - ); + )); $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); diff --git a/tests/PublicChannelTest.php b/tests/PublicChannelTest.php index c444d244d7..b37b651696 100644 --- a/tests/PublicChannelTest.php +++ b/tests/PublicChannelTest.php @@ -5,6 +5,7 @@ use BeyondCode\LaravelWebSockets\API\TriggerEvent; use GuzzleHttp\Psr7\Request; use Illuminate\Http\JsonResponse; +use Pusher\Pusher; use Ratchet\ConnectionInterface; class PublicChannelTest extends TestCase @@ -209,41 +210,28 @@ public function test_events_get_replicated_across_connections_for_public_channel public function test_it_fires_the_event_to_public_channel() { - $this->newActiveConnection(['public-channel']); - - $connection = new Mocks\Connection; + $this->startServer(); $requestPath = '/apps/1234/events'; - $routeParams = [ - 'appId' => '1234', - ]; - - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'TestSecret', 'POST', $requestPath, [ 'name' => 'some-event', 'channels' => ['public-channel'], 'data' => json_encode(['some-data' => 'yes']), ], - ); + )); - $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + $response = $this->await($this->browser->post('http://localhost:4000'."{$requestPath}?{$queryString}")); - $controller = app(TriggerEvent::class); - - $controller->onOpen($connection, $request); - - /** @var JsonResponse $response */ - $response = array_pop($connection->sentRawData); - - $this->assertSame([], json_decode($response->getContent(), true)); + $this->assertSame([], json_decode((string) $response->getBody(), true)); $this->statisticsCollector ->getAppStatistics('1234') ->then(function ($statistic) { $this->assertEquals([ - 'peak_connections_count' => 1, - 'websocket_messages_count' => 1, + 'peak_connections_count' => 0, + 'websocket_messages_count' => 0, 'api_messages_count' => 1, 'app_id' => '1234', ], $statistic->toArray()); @@ -260,13 +248,13 @@ public function test_it_fires_event_across_servers_when_there_are_not_users_loca 'appId' => '1234', ]; - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'TestSecret', 'POST', $requestPath, [ 'name' => 'some-event', 'channels' => ['public-channel'], 'data' => json_encode(['some-data' => 'yes']), ], - ); + )); $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); @@ -308,13 +296,13 @@ public function test_it_fires_event_across_servers_when_there_are_users_locally_ 'appId' => '1234', ]; - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'TestSecret', 'POST', $requestPath, [ 'name' => 'some-event', 'channels' => ['public-channel'], 'data' => json_encode(['some-data' => 'yes']), ], - ); + )); $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); diff --git a/tests/TestCase.php b/tests/TestCase.php index 6d4853a042..618759411e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -7,14 +7,39 @@ use BeyondCode\LaravelWebSockets\Contracts\StatisticsStore; use BeyondCode\LaravelWebSockets\Facades\WebSocketRouter; use BeyondCode\LaravelWebSockets\Helpers; +use BeyondCode\LaravelWebSockets\Server\Loggers\HttpLogger; +use BeyondCode\LaravelWebSockets\Server\Loggers\WebSocketsLogger; +use BeyondCode\LaravelWebSockets\ServerFactory; +use function Clue\React\Block\await; +use Clue\React\Buzz\Browser; use GuzzleHttp\Psr7\Request; use Illuminate\Support\Facades\Redis; use Orchestra\Testbench\BrowserKit\TestCase as Orchestra; -use Pusher\Pusher; +use Ratchet\Server\IoServer; use React\EventLoop\Factory as LoopFactory; +use React\EventLoop\LoopInterface; +use React\Promise\Deferred; +use React\Promise\PromiseInterface; +use Symfony\Component\Console\Output\BufferedOutput; abstract class TestCase extends Orchestra { + const AWAIT_TIMEOUT = 5.0; + + /** + * The test Browser. + * + * @var \Clue\React\Buzz\Browser + */ + protected $browser; + + /** + * The test WebSocket server. + * + * @var IoServer + */ + protected $server; + /** * A test Pusher server. * @@ -73,11 +98,31 @@ public function setUp(): void $this->loop = LoopFactory::create(); + $this->app->singleton(LoopInterface::class, function () { + return $this->loop; + }); + + $this->browser = (new Browser($this->loop)) + ->withFollowRedirects(false) + ->withRejectErrorResponse(false); + + $this->app->singleton(HttpLogger::class, function () { + return (new HttpLogger(new BufferedOutput())) + ->enable(false) + ->verbose(false); + }); + + $this->app->singleton(WebSocketsLogger::class, function () { + return (new WebSocketsLogger(new BufferedOutput())) + ->enable(false) + ->verbose(false); + }); + $this->replicationMode = getenv('REPLICATION_MODE') ?: 'local'; $this->resetDatabase(); $this->loadLaravelMigrations(['--database' => 'sqlite']); - $this->loadMigrationsFrom(__DIR__.'/database/migrations'); + $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); $this->withFactories(__DIR__.'/database/factories'); $this->registerCustomPath(); @@ -102,6 +147,15 @@ public function setUp(): void } } + protected function tearDown(): void + { + parent::tearDown(); + + if ($this->server) { + $this->server->socket->close(); + } + } + /** * {@inheritdoc} */ @@ -270,6 +324,11 @@ protected function registerManagers() $this->channelManager = $this->app->make(ChannelManager::class); } + protected function await(PromiseInterface $promise, LoopInterface $loop = null, $timeout = null) + { + return await($promise, $loop ?? $this->loop, $timeout ?? static::AWAIT_TIMEOUT); + } + /** * Unregister the managers for testing purposes. * @@ -338,6 +397,19 @@ protected function newConnection(string $appKey = 'TestKey', array $headers = [] return $connection; } + protected function joinWebSocketServer(array $channelsToJoin = [], string $appKey = 'TestKey', array $headers = []) + { + $promise = new Deferred(); + + \Ratchet\Client\connect("ws://localhost:4000/app/{$appKey}", [], [], $this->loop)->then(function ($conn) use ($promise) { + $conn->on('message', function ($msg) use ($promise) { + $promise->resolve($msg); + }); + }); + + return $promise->promise(); + } + /** * Get a connected websocket connection. * @@ -485,27 +557,16 @@ protected function skipOnLocalReplication() } } - protected static function build_auth_query_string( - $auth_key, - $auth_secret, - $request_method, - $request_path, - $query_params = [], - $auth_version = '1.0', - $auth_timestamp = null - ) { - $method = method_exists(Pusher::class, 'build_auth_query_params') ? 'build_auth_query_params' : 'build_auth_query_string'; - - $params = Pusher::$method( - $auth_key, $auth_secret, $request_method, $request_path, $query_params, $auth_version, $auth_timestamp - ); - - if ($method == 'build_auth_query_string') { - return $params; - } + protected function startServer() + { + $server = new ServerFactory('0.0.0.0', 4000); - ksort($params); + WebSocketRouter::registerRoutes(); - return http_build_query($params); + $this->server = $server + ->setLoop($this->loop) + ->withRoutes(WebSocketRouter::getRoutes()) + ->setConsoleOutput(new BufferedOutput()) + ->createServer(); } } diff --git a/tests/TriggerEventTest.php b/tests/TriggerEventTest.php index 18a487416d..4f97c8d685 100644 --- a/tests/TriggerEventTest.php +++ b/tests/TriggerEventTest.php @@ -2,33 +2,25 @@ namespace BeyondCode\LaravelWebSockets\Test; -use BeyondCode\LaravelWebSockets\API\TriggerEvent; -use GuzzleHttp\Psr7\Request; -use Symfony\Component\HttpKernel\Exception\HttpException; +use Pusher\Pusher; class TriggerEventTest extends TestCase { public function test_invalid_signatures_can_not_fire_the_event() { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Invalid auth signature provided.'); + $this->startServer(); $connection = new Mocks\Connection; $requestPath = '/apps/1234/events'; - $routeParams = [ - 'appId' => '1234', - ]; - - $queryString = self::build_auth_query_string( + $queryString = http_build_query(Pusher::build_auth_query_params( 'TestKey', 'InvalidSecret', 'GET', $requestPath - ); - - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + )); - $controller = app(TriggerEvent::class); + $response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}")); - $controller->onOpen($connection, $request); + $this->assertSame(405, $response->getStatusCode()); + $this->assertSame('', $response->getBody()->getContents()); } } diff --git a/tests/database/migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php b/tests/database/migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php deleted file mode 100644 index 0989f288c5..0000000000 --- a/tests/database/migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php +++ /dev/null @@ -1,35 +0,0 @@ -increments('id'); - $table->string('app_id'); - $table->integer('peak_connections_count'); - $table->integer('websocket_messages_count'); - $table->integer('api_messages_count'); - $table->nullableTimestamps(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('websockets_statistics_entries'); - } -}