diff --git a/.editorconfig b/.editorconfig index 32de2af..9718070 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,7 +11,7 @@ end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true -[*.blade.php] +[*.{blade.php,yml,yaml}] indent_size = 2 [*.md] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8ab4ff7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: + - '*' + tags: + - '*' + pull_request: + branches: + - '*' + +jobs: + build: + if: "!contains(github.event.head_commit.message, 'skip ci')" + + runs-on: ubuntu-latest + + strategy: + matrix: + php: ['7.2', '7.3', '7.4'] + laravel: ['6.*', '7.*', '8.*'] + prefer: ['prefer-lowest', 'prefer-stable'] + include: + - laravel: '6.*' + testbench: '4.*' + - laravel: '7.*' + testbench: '5.*' + - laravel: '8.*' + testbench: '6.*' + + name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} --${{ matrix.prefer }} + + steps: + - uses: actions/checkout@v1 + + - name: Setup Redis + uses: supercharge/redis-github-action@1.1.0 + with: + redis-version: 6 + + - uses: actions/cache@v1 + name: Cache dependencies + with: + path: ~/.composer/cache/files + key: composer-php-${{ matrix.php }}-${{ matrix.laravel }}-${{ matrix.prefer }}-${{ hashFiles('composer.json') }} + + - name: Install dependencies + run: | + 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 + run: | + REPLICATION_MODE=local phpunit --coverage-text --coverage-clover=coverage_local.xml + + - name: Run tests for Redis + run: | + REPLICATION_MODE=redis phpunit --coverage-text --coverage-clover=coverage_redis.xml + + - uses: codecov/codecov-action@v1 + with: + fail_ci_if_error: false + file: '*.xml' + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index c3e4762..0000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: run-tests - -on: [push, pull_request] - -jobs: - test: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - php: [7.4, 7.3, 7.2] - laravel: [6.*, 7.*] - dependency-version: [prefer-lowest, prefer-stable] - include: - - laravel: 7.* - testbench: 5.* - - laravel: 6.* - testbench: 4.* - - name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} - - steps: - - name: Checkout code - uses: actions/checkout@v1 - - - name: Setup Redis - uses: supercharge/redis-github-action@1.1.0 - with: - redis-version: 6 - if: ${{ matrix.os == 'ubuntu-latest' }} - - - name: Cache dependencies - uses: actions/cache@v1 - with: - path: ~/.composer/cache/files - key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick - coverage: xdebug - - - name: Install dependencies - run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench-browser-kit:${{ matrix.testbench }}" --no-interaction --no-update - composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest - - - name: Execute tests with Local driver - run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage_local.xml - env: - REPLICATION_DRIVER: local - - - name: Execute tests with Redis driver - run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage_redis.xml - if: ${{ matrix.os == 'ubuntu-latest' }} - env: - REPLICATION_DRIVER: redis - - - uses: codecov/codecov-action@v1 - with: - fail_ci_if_error: false - file: '*.xml' diff --git a/.gitignore b/.gitignore index a4753bd..65e1146 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ +/vendor +/.idea build -composer.lock -vendor -coverage .phpunit.result.cache -.idea/ +coverage +composer.phar +composer.lock +.DS_Store database.sqlite diff --git a/.scrutinizer.yml b/.scrutinizer.yml index df16b68..76733d0 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,19 +1,19 @@ filter: - excluded_paths: [tests/*] + excluded_paths: [tests/*] checks: - php: - remove_extra_empty_lines: true - remove_php_closing_tag: true - remove_trailing_whitespace: true - fix_use_statements: - remove_unused: true - preserve_multiple: false - preserve_blanklines: true - order_alphabetically: true - fix_php_opening_tag: true - fix_linefeed: true - fix_line_ending: true - fix_identation_4spaces: true - fix_doc_comments: true + php: + remove_extra_empty_lines: true + remove_php_closing_tag: true + remove_trailing_whitespace: true + fix_use_statements: + remove_unused: true + preserve_multiple: false + preserve_blanklines: true + order_alphabetically: true + fix_php_opening_tag: true + fix_linefeed: true + fix_line_ending: true + fix_identation_4spaces: true + fix_doc_comments: true diff --git a/.styleci.yml b/.styleci.yml index f4d3cbc..c3bb259 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -1,4 +1 @@ -preset: laravel - -disabled: - - single_class_element_per_statement +preset: laravel \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index a3341a8..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,21 +0,0 @@ -# Changelog - -All notable changes to `laravel-websockets` will be documented in this file - -## 1.4.0 - 2020-03-03 - -- add support for Laravel 7 - -## 1.0.2 - 2018-12-06 - -- Fix issue with wrong namespaces - -## 1.0.1 - 2018-12-04 - -- Remove VueJS debug mode on dashboard -- Allow setting app hosts to use when connecting via the dashboard -- Added debug mode when starting the WebSocket server - -## 1.0.0 - 2018-12-04 - -- initial release diff --git a/LICENSE.md b/LICENSE similarity index 100% rename from LICENSE.md rename to LICENSE diff --git a/composer.json b/composer.json index 09c15b6..50592a1 100644 --- a/composer.json +++ b/composer.json @@ -1,12 +1,9 @@ { "name": "beyondcode/laravel-websockets", - "description": "An easy to use WebSocket server", - "keywords": [ - "beyondcode", - "laravel-websockets" - ], - "homepage": "https://github.com/beyondcode/laravel-websockets", + "description": ":package_description", + "keywords": ["laravel", "php"], "license": "MIT", + "homepage": "https://github.com/beyondcode/laravel-websockets", "authors": [ { "name": "Marcel Pociot", @@ -19,6 +16,11 @@ "email": "freek@spatie.be", "homepage": "https://spatie.be", "role": "Developer" + }, + { + "name": "Alex Renoki", + "homepage": "https://github.com/rennokki", + "role": "Developer" } ], "require": { @@ -28,50 +30,43 @@ "clue/buzz-react": "^2.5", "clue/redis-react": "^2.3", "evenement/evenement": "^2.0|^3.0", - "facade/ignition-contracts": "^1.0", "guzzlehttp/psr7": "^1.5", - "illuminate/broadcasting": "^6.0|^7.0", - "illuminate/console": "^6.0|^7.0", - "illuminate/http": "^6.0|^7.0", - "illuminate/routing": "^6.0|^7.0", - "illuminate/support": "^6.0|^7.0", + "laravel/framework": "^6.0|^7.0|^8.0", "pusher/pusher-php-server": "^3.0|^4.0", "react/promise": "^2.0", "symfony/http-kernel": "^4.0|^5.0", "symfony/psr-http-message-bridge": "^1.1|^2.0" }, - "require-dev": { - "clue/block-react": "^1.4", - "mockery/mockery": "^1.3", - "orchestra/testbench-browser-kit": "^4.0|^5.0", - "phpunit/phpunit": "^8.0|^9.0" - }, "autoload": { "psr-4": { - "BeyondCode\\LaravelWebSockets\\": "src" + "BeyondCode\\LaravelWebSockets\\": "src/" } }, "autoload-dev": { "psr-4": { - "BeyondCode\\LaravelWebSockets\\Tests\\": "tests" + "BeyondCode\\LaravelWebSockets\\Test\\": "tests" } }, "scripts": { - "test": "vendor/bin/phpunit", - "test-coverage": "vendor/bin/phpunit --coverage-html coverage" - + "test": "vendor/bin/phpunit" + }, + "require-dev": { + "clue/block-react": "^1.4", + "laravel/legacy-factories": "^1.0.4", + "mockery/mockery": "^1.3", + "orchestra/testbench-browser-kit": "^4.0|^5.0|^6.0", + "orchestra/database": "^4.0|^5.0|^6.0", + "phpunit/phpunit": "^8.0|^9.0" }, "config": { "sort-packages": true }, + "minimum-stability": "dev", "extra": { "laravel": { "providers": [ "BeyondCode\\LaravelWebSockets\\WebSocketsServiceProvider" - ], - "aliases": { - "WebSocketRouter": "BeyondCode\\LaravelWebSockets\\Facades\\WebSocketRouter" - } + ] } } } diff --git a/config/websockets.php b/config/websockets.php index f5d9faf..e36f3cd 100644 --- a/config/websockets.php +++ b/config/websockets.php @@ -76,6 +76,141 @@ return [ ], ], + /* + |-------------------------------------------------------------------------- + | Broadcasting Replication PubSub + |-------------------------------------------------------------------------- + | + | You can enable replication to publish and subscribe to + | messages across the driver. + | + | By default, it is set to 'local', but you can configure it to use drivers + | like Redis to ensure connection between multiple instances of + | WebSocket servers. Just set the driver to 'redis' to enable the PubSub using Redis. + | + */ + + 'replication' => [ + + 'mode' => env('WEBSOCKETS_REPLICATION_MODE', 'local'), + + 'modes' => [ + + /* + |-------------------------------------------------------------------------- + | Local Replication + |-------------------------------------------------------------------------- + | + | Local replication is actually a null replicator, meaning that it + | is the default behaviour of storing the connections into an array. + | + */ + + 'local' => [ + + /* + |-------------------------------------------------------------------------- + | Channel Manager + |-------------------------------------------------------------------------- + | + | The channel manager is responsible for storing, tracking and retrieving + | the channels as long as their memebers and connections. + | + */ + + 'channel_manager' => \BeyondCode\LaravelWebSockets\ChannelManagers\LocalChannelManager::class, + + /* + |-------------------------------------------------------------------------- + | Statistics Collector + |-------------------------------------------------------------------------- + | + | The Statistics Collector will, by default, handle the incoming statistics, + | storing them until they will become dumped into another database, usually + | a MySQL database or a time-series database. + | + */ + + 'collector' => \BeyondCode\LaravelWebSockets\Statistics\Collectors\MemoryCollector::class, + + ], + + 'redis' => [ + + 'connection' => 'default', + + /* + |-------------------------------------------------------------------------- + | Channel Manager + |-------------------------------------------------------------------------- + | + | The channel manager is responsible for storing, tracking and retrieving + | the channels as long as their memebers and connections. + | + */ + + 'channel_manager' => \BeyondCode\LaravelWebSockets\ChannelManagers\RedisChannelManager::class, + + /* + |-------------------------------------------------------------------------- + | Statistics Collector + |-------------------------------------------------------------------------- + | + | The Statistics Collector will, by default, handle the incoming statistics, + | storing them until they will become dumped into another database, usually + | a MySQL database or a time-series database. + | + */ + + 'collector' => \BeyondCode\LaravelWebSockets\Statistics\Collectors\RedisCollector::class, + + ], + + ], + + ], + + 'statistics' => [ + + /* + |-------------------------------------------------------------------------- + | Statistics Store + |-------------------------------------------------------------------------- + | + | The Statistics Store is the place where all the temporary stats will + | be dumped. This is a much reliable store and will be used to display + | graphs or handle it later on your app. + | + */ + + 'store' => \BeyondCode\LaravelWebSockets\Statistics\Stores\DatabaseStore::class, + + /* + |-------------------------------------------------------------------------- + | Statistics Interval Period + |-------------------------------------------------------------------------- + | + | Here you can specify the interval in seconds at which + | statistics should be logged. + | + */ + + 'interval_in_seconds' => 60, + + /* + |-------------------------------------------------------------------------- + | Statistics Deletion Period + |-------------------------------------------------------------------------- + | + | When the clean-command is executed, all recorded statistics older than + | the number of days specified here will be deleted. + | + */ + + 'delete_statistics_older_than_days' => 60, + + ], + /* |-------------------------------------------------------------------------- | Maximum Request Size @@ -130,130 +265,15 @@ return [ 'handlers' => [ - 'websocket' => \BeyondCode\LaravelWebSockets\WebSockets\WebSocketHandler::class, + 'websocket' => \BeyondCode\LaravelWebSockets\Server\WebSocketHandler::class, - 'trigger_event' => \BeyondCode\LaravelWebSockets\HttpApi\Controllers\TriggerEventController::class, + 'trigger_event' => \BeyondCode\LaravelWebSockets\API\TriggerEvent::class, - 'fetch_channels' => \BeyondCode\LaravelWebSockets\HttpApi\Controllers\FetchChannelsController::class, + 'fetch_channels' => \BeyondCode\LaravelWebSockets\API\FetchChannels::class, - 'fetch_channel' => \BeyondCode\LaravelWebSockets\HttpApi\Controllers\FetchChannelController::class, + 'fetch_channel' => \BeyondCode\LaravelWebSockets\API\FetchChannel::class, - 'fetch_users' => \BeyondCode\LaravelWebSockets\HttpApi\Controllers\FetchUsersController::class, - - ], - - /* - |-------------------------------------------------------------------------- - | Broadcasting Replication PubSub - |-------------------------------------------------------------------------- - | - | You can enable replication to publish and subscribe to - | messages across the driver. - | - | By default, it is set to 'local', but you can configure it to use drivers - | like Redis to ensure connection between multiple instances of - | WebSocket servers. Just set the driver to 'redis' to enable the PubSub using Redis. - | - */ - - 'replication' => [ - - 'driver' => env('LARAVEL_WEBSOCKETS_REPLICATION_DRIVER', 'local'), - - /* - |-------------------------------------------------------------------------- - | Local Replication - |-------------------------------------------------------------------------- - | - | Local replication is actually a null replicator, meaning that it - | is the default behaviour of storing the connections into an array. - | - */ - - 'local' => [ - - 'client' => \BeyondCode\LaravelWebSockets\PubSub\Drivers\LocalClient::class, - - 'statistics_logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger::class, - - 'channel_manager' => \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\ArrayChannelManager::class, - - ], - - /* - |-------------------------------------------------------------------------- - | Redis Replication - |-------------------------------------------------------------------------- - | - | Redis replication relies on the Redis' Pub/Sub protocol. When users - | are connected across multiple nodes, whenever some event gets triggered - | on one instance, the rest of the instances get the same copy and, in - | case the connected users to other instances are valid to receive - | the event, they will receive it. - | - */ - - 'redis' => [ - - 'connection' => env('LARAVEL_WEBSOCKETS_REPLICATION_CONNECTION', 'default'), - - 'client' => \BeyondCode\LaravelWebSockets\PubSub\Drivers\RedisClient::class, - - 'statistics_logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\RedisStatisticsLogger::class, - - 'channel_manager' => \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\RedisChannelManager::class, - - ], - - ], - - 'statistics' => [ - - /* - |-------------------------------------------------------------------------- - | Statistics Driver - |-------------------------------------------------------------------------- - | - | Here you can specify which driver to use to store the statistics to. - | See down below for each driver's setting. - | - | Available: database - | - */ - - 'driver' => env('LARAVEL_WEBSOCKETS_STATISTICS_DRIVER', 'database'), - - 'database' => [ - - 'driver' => \BeyondCode\LaravelWebSockets\Statistics\Drivers\DatabaseDriver::class, - - 'model' => \BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry::class, - - ], - - /* - |-------------------------------------------------------------------------- - | Statistics Interval Period - |-------------------------------------------------------------------------- - | - | Here you can specify the interval in seconds at which - | statistics should be logged. - | - */ - - 'interval_in_seconds' => 60, - - /* - |-------------------------------------------------------------------------- - | Statistics Deletion Period - |-------------------------------------------------------------------------- - | - | When the clean-command is executed, all recorded statistics older than - | the number of days specified here will be deleted. - | - */ - - 'delete_statistics_older_than_days' => 60, + 'fetch_users' => \BeyondCode\LaravelWebSockets\API\FetchUsers::class, ], 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 1b89b4a..0989f28 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 @@ class CreateWebSocketsStatisticsEntriesTable extends Migration 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/phpunit.xml.dist b/phpunit.xml similarity index 82% rename from phpunit.xml.dist rename to phpunit.xml index 179f0b3..229ec35 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml @@ -10,7 +10,7 @@ processIsolation="false" stopOnFailure="false"> - + tests @@ -20,6 +20,7 @@ - + + diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index b2ce662..a7d9a76 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -395,8 +395,6 @@ let payload = { _token: '{{ csrf_token() }}', - key: this.app.key, - secret: this.app.secret, appId: this.app.id, channel: this.form.channel, event: this.form.event, @@ -424,10 +422,6 @@ return 'bg-green-700 text-white'; } - if (log.type === 'vacated') { - return 'bg-orange-500 text-white'; - } - if (['disconnection', 'occupied', 'replicator-unsubscribed', 'replicator-left'].includes(log.type)) { return 'bg-red-700 text-white'; } diff --git a/src/HttpApi/Controllers/Controller.php b/src/API/Controller.php similarity index 85% rename from src/HttpApi/Controllers/Controller.php rename to src/API/Controller.php index cd47d1e..74267de 100644 --- a/src/HttpApi/Controllers/Controller.php +++ b/src/API/Controller.php @@ -1,11 +1,10 @@ channelManager = $channelManager; - $this->replicator = $replicator; } /** @@ -202,6 +192,10 @@ abstract class Controller implements HttpServerInterface return; } + if ($response instanceof HttpException) { + throw $response; + } + $this->sendAndClose($connection, $response); } @@ -243,11 +237,12 @@ abstract class Controller implements HttpServerInterface */ protected function ensureValidSignature(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. - */ - $params = Arr::except($request->query(), ['auth_signature', 'body_md5', 'appId', 'appKey', 'channelName']); + // 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. + + $params = Arr::except($request->query(), [ + 'auth_signature', 'body_md5', 'appId', 'appKey', 'channelName', + ]); if ($request->getContent() !== '') { $params['body_md5'] = md5($request->getContent()); @@ -257,7 +252,9 @@ abstract class Controller implements HttpServerInterface $signature = "{$request->getMethod()}\n/{$request->path()}\n".Pusher::array_implode('=', '&', $params); - $authSignature = hash_hmac('sha256', $signature, App::findById($request->get('appId'))->secret); + $app = App::findById($request->get('appId')); + + $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/FetchChannel.php b/src/API/FetchChannel.php new file mode 100644 index 0000000..a0c20fa --- /dev/null +++ b/src/API/FetchChannel.php @@ -0,0 +1,52 @@ +channelManager->find( + $request->appId, $request->channelName + ); + + if (is_null($channel)) { + return new HttpException(404, "Unknown channel `{$request->channelName}`."); + } + + return $this->channelManager + ->getGlobalConnectionsCount($request->appId, $request->channelName) + ->then(function ($connectionsCount) use ($request) { + // For the presence channels, we need a slightly different response + // that need an additional call. + if (Str::startsWith($request->channelName, 'presence-')) { + return $this->channelManager + ->getChannelsMembersCount($request->appId, [$request->channelName]) + ->then(function ($channelMembers) use ($connectionsCount, $request) { + return [ + 'occupied' => $connectionsCount > 0, + 'subscription_count' => $connectionsCount, + 'user_count' => $channelMembers[$request->channelName] ?? 0, + ]; + }); + } + + // For the rest of the channels, we might as well + // send the basic response with the subscriptions count. + return [ + 'occupied' => $connectionsCount > 0, + 'subscription_count' => $connectionsCount, + ]; + }); + } +} diff --git a/src/API/FetchChannels.php b/src/API/FetchChannels.php new file mode 100644 index 0000000..dcfd74f --- /dev/null +++ b/src/API/FetchChannels.php @@ -0,0 +1,79 @@ +has('info')) { + $attributes = explode(',', trim($request->info)); + + if (in_array('user_count', $attributes) && ! Str::startsWith($request->filter_by_prefix, 'presence-')) { + throw new HttpException(400, 'Request must be limited to presence channels in order to fetch user_count'); + } + } + + return $this->channelManager + ->getGlobalChannels($request->appId) + ->then(function ($channels) use ($request, $attributes) { + $channels = collect($channels)->keyBy(function ($channel) { + return $channel instanceof Channel + ? $channel->getName() + : $channel; + }); + + if ($request->has('filter_by_prefix')) { + $channels = $channels->filter(function ($channel, $channelName) use ($request) { + return Str::startsWith($channelName, $request->filter_by_prefix); + }); + } + + $channelNames = $channels->map(function ($channel) { + return $channel instanceof Channel + ? $channel->getName() + : $channel; + })->toArray(); + + return $this->channelManager + ->getChannelsMembersCount($request->appId, $channelNames) + ->then(function ($counts) use ($channels, $attributes) { + $channels = $channels->map(function ($channel) use ($counts, $attributes) { + $info = new stdClass; + + $channelName = $channel instanceof Channel + ? $channel->getName() + : $channel; + + if (in_array('user_count', $attributes)) { + $info->user_count = $counts[$channelName]; + } + + return $info; + }) + ->sortBy(function ($content, $name) { + return $name; + }) + ->all(); + + return [ + 'channels' => $channels ?: new stdClass, + ]; + }); + }); + } +} diff --git a/src/API/FetchUsers.php b/src/API/FetchUsers.php new file mode 100644 index 0000000..5327847 --- /dev/null +++ b/src/API/FetchUsers.php @@ -0,0 +1,35 @@ +channelName, 'presence-')) { + return new HttpException(400, "Invalid presence channel `{$request->channelName}`"); + } + + return $this->channelManager + ->getChannelMembers($request->appId, $request->channelName) + ->then(function ($members) { + $users = collect($members)->map(function ($user) { + return ['id' => $user->user_id]; + })->values()->toArray(); + + return [ + 'users' => $users, + ]; + }); + } +} diff --git a/src/API/TriggerEvent.php b/src/API/TriggerEvent.php new file mode 100644 index 0000000..9f66e63 --- /dev/null +++ b/src/API/TriggerEvent.php @@ -0,0 +1,63 @@ +channels ?: []; + + if (is_string($channels)) { + $channels = [$channels]; + } + + foreach ($channels as $channelName) { + // Here you can use the ->find(), even if the channel + // does not exist on the server. If it does not exist, + // then the message simply will get broadcasted + // across the other servers. + $channel = $this->channelManager->find( + $request->appId, $channelName + ); + + $payload = [ + 'channel' => $channelName, + 'event' => $request->name, + 'data' => $request->data, + ]; + + if ($channel) { + $channel->broadcastToEveryoneExcept( + (object) $payload, + $request->socket_id, + $request->appId + ); + } else { + $this->channelManager->broadcastAcrossServers( + $request->appId, $channelName, (object) $payload + ); + } + + StatisticsCollector::apiMessage($request->appId); + + DashboardLogger::log($request->appId, DashboardLogger::TYPE_API_MESSAGE, [ + 'channel' => $channelName, + 'event' => $request->name, + 'payload' => $request->data, + ]); + } + + return $request->json()->all(); + } +} diff --git a/src/Apps/App.php b/src/Apps/App.php index acb2150..19d10f6 100644 --- a/src/Apps/App.php +++ b/src/Apps/App.php @@ -2,7 +2,7 @@ namespace BeyondCode\LaravelWebSockets\Apps; -use BeyondCode\LaravelWebSockets\Exceptions\InvalidApp; +use BeyondCode\LaravelWebSockets\Contracts\AppManager; class App { @@ -76,18 +76,9 @@ class App * @param string $key * @param string $secret * @return void - * @throws \BeyondCode\LaravelWebSockets\Exceptions\InvalidApp */ public function __construct($appId, $appKey, $appSecret) { - if ($appKey === '') { - throw InvalidApp::valueIsRequired('appKey', $appId); - } - - if ($appSecret === '') { - throw InvalidApp::valueIsRequired('appSecret', $appId); - } - $this->id = $appId; $this->key = $appKey; $this->secret = $appSecret; diff --git a/src/Apps/ConfigAppManager.php b/src/Apps/ConfigAppManager.php index 03e5458..eb3d5db 100644 --- a/src/Apps/ConfigAppManager.php +++ b/src/Apps/ConfigAppManager.php @@ -2,6 +2,8 @@ namespace BeyondCode\LaravelWebSockets\Apps; +use BeyondCode\LaravelWebSockets\Contracts\AppManager; + class ConfigAppManager implements AppManager { /** @@ -30,7 +32,7 @@ class ConfigAppManager implements AppManager { return $this->apps ->map(function (array $appAttributes) { - return $this->instantiate($appAttributes); + return $this->convertIntoApp($appAttributes); }) ->toArray(); } @@ -43,11 +45,9 @@ class ConfigAppManager implements AppManager */ public function findById($appId): ?App { - $appAttributes = $this - ->apps - ->firstWhere('id', $appId); - - return $this->instantiate($appAttributes); + return $this->convertIntoApp( + $this->apps->firstWhere('id', $appId) + ); } /** @@ -58,11 +58,9 @@ class ConfigAppManager implements AppManager */ public function findByKey($appKey): ?App { - $appAttributes = $this - ->apps - ->firstWhere('key', $appKey); - - return $this->instantiate($appAttributes); + return $this->convertIntoApp( + $this->apps->firstWhere('key', $appKey) + ); } /** @@ -73,11 +71,9 @@ class ConfigAppManager implements AppManager */ public function findBySecret($appSecret): ?App { - $appAttributes = $this - ->apps - ->firstWhere('secret', $appSecret); - - return $this->instantiate($appAttributes); + return $this->convertIntoApp( + $this->apps->firstWhere('secret', $appSecret) + ); } /** @@ -86,7 +82,7 @@ class ConfigAppManager implements AppManager * @param array|null $app * @return \BeyondCode\LaravelWebSockets\Apps\App|null */ - protected function instantiate(?array $appAttributes): ?App + protected function convertIntoApp(?array $appAttributes): ?App { if (! $appAttributes) { return null; diff --git a/src/ChannelManagers/LocalChannelManager.php b/src/ChannelManagers/LocalChannelManager.php new file mode 100644 index 0000000..2b8150c --- /dev/null +++ b/src/ChannelManagers/LocalChannelManager.php @@ -0,0 +1,334 @@ +channels[$appId][$channel] ?? null; + } + + /** + * Find a channel by app & name or create one. + * + * @param string|int $appId + * @param string $channel + * @return BeyondCode\LaravelWebSockets\Channels\Channel + */ + public function findOrCreate($appId, string $channel) + { + if (! $channelInstance = $this->find($appId, $channel)) { + $class = $this->getChannelClassName($channel); + + $this->channels[$appId][$channel] = new $class($channel); + } + + return $this->channels[$appId][$channel]; + } + + /** + * Get all channels for a specific app + * for the current instance. + * + * @param string|int $appId + * @return \React\Promise\PromiseInterface[array] + */ + public function getLocalChannels($appId): PromiseInterface + { + return new FulfilledPromise( + $this->channels[$appId] ?? [] + ); + } + + /** + * Get all channels for a specific app + * across multiple servers. + * + * @param string|int $appId + * @return \React\Promise\PromiseInterface[array] + */ + public function getGlobalChannels($appId): PromiseInterface + { + return $this->getLocalChannels($appId); + } + + /** + * Remove connection from all channels. + * + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ + public function unsubscribeFromAllChannels(ConnectionInterface $connection) + { + if (! isset($connection->app)) { + return; + } + + $this->getLocalChannels($connection->app->id) + ->then(function ($channels) use ($connection) { + collect($channels)->each->unsubscribe($connection); + + collect($channels) + ->reject->hasConnections() + ->each(function (Channel $channel, string $channelName) use ($connection) { + unset($this->channels[$connection->app->id][$channelName]); + }); + }); + + $this->getLocalChannels($connection->app->id) + ->then(function ($channels) use ($connection) { + if (count($channels) === 0) { + unset($this->channels[$connection->app->id]); + } + }); + } + + /** + * Subscribe the connection to a specific channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param string $channelName + * @param stdClass $payload + * @return void + */ + public function subscribeToChannel(ConnectionInterface $connection, string $channelName, stdClass $payload) + { + $channel = $this->findOrCreate($connection->app->id, $channelName); + + $channel->subscribe($connection, $payload); + } + + /** + * Unsubscribe the connection from the channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param string $channelName + * @param stdClass $payload + * @return void + */ + public function unsubscribeFromChannel(ConnectionInterface $connection, string $channelName, stdClass $payload) + { + $channel = $this->findOrCreate($connection->app->id, $channelName); + + $channel->unsubscribe($connection, $payload); + } + + /** + * Subscribe the connection to a specific channel. + * + * @param string|int $appId + * @return void + */ + public function subscribeToApp($appId) + { + // + } + + /** + * Unsubscribe the connection from the channel. + * + * @param string|int $appId + * @return void + */ + public function unsubscribeFromApp($appId) + { + // + } + + /** + * Get the connections count on the app + * for the current server instance. + * + * @param string|int $appId + * @param string|null $channelName + * @return \React\Promise\PromiseInterface + */ + public function getLocalConnectionsCount($appId, string $channelName = null): PromiseInterface + { + return $this->getLocalChannels($appId) + ->then(function ($channels) use ($channelName) { + return collect($channels) + ->when(! is_null($channelName), function ($collection) use ($channelName) { + return $collection->filter(function (Channel $channel) use ($channelName) { + return $channel->getName() === $channelName; + }); + }) + ->flatMap(function (Channel $channel) { + return collect($channel->getConnections())->pluck('socketId'); + }) + ->unique() + ->count(); + }); + } + + /** + * Get the connections count + * across multiple servers. + * + * @param string|int $appId + * @param string|null $channelName + * @return \React\Promise\PromiseInterface + */ + public function getGlobalConnectionsCount($appId, string $channelName = null): PromiseInterface + { + return $this->getLocalConnectionsCount($appId, $channelName); + } + + /** + * Broadcast the message across multiple servers. + * + * @param string|int $appId + * @param string $channel + * @param stdClass $payload + * @return bool + */ + public function broadcastAcrossServers($appId, string $channel, stdClass $payload) + { + return true; + } + + /** + * Handle the user when it joined a presence channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param stdClass $user + * @param string $channel + * @param stdClass $payload + * @return void + */ + public function userJoinedPresenceChannel(ConnectionInterface $connection, stdClass $user, string $channel, stdClass $payload) + { + $this->users["{$connection->app->id}:{$channel}"][$connection->socketId] = json_encode($user); + } + + /** + * Handle the user when it left a presence channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param stdClass $user + * @param string $channel + * @param stdClass $payload + * @return void + */ + public function userLeftPresenceChannel(ConnectionInterface $connection, stdClass $user, string $channel) + { + unset($this->users["{$connection->app->id}:{$channel}"][$connection->socketId]); + } + + /** + * Get the presence channel members. + * + * @param string|int $appId + * @param string $channel + * @return \React\Promise\PromiseInterface + */ + public function getChannelMembers($appId, string $channel): PromiseInterface + { + $members = $this->users["{$appId}:{$channel}"] ?? []; + + $members = collect($members)->map(function ($user) { + return json_decode($user); + })->toArray(); + + return new FulfilledPromise($members); + } + + /** + * Get a member from a presence channel based on connection. + * + * @param \Ratchet\ConnectionInterface $connection + * @param string $channel + * @return \React\Promise\PromiseInterface + */ + public function getChannelMember(ConnectionInterface $connection, string $channel): PromiseInterface + { + $member = $this->users["{$connection->app->id}:{$channel}"][$connection->socketId] ?? null; + + return new FulfilledPromise($member); + } + + /** + * Get the presence channels total members count. + * + * @param string|int $appId + * @param array $channelNames + * @return \React\Promise\PromiseInterface + */ + public function getChannelsMembersCount($appId, array $channelNames): PromiseInterface + { + $results = collect($channelNames) + ->reduce(function ($results, $channel) use ($appId) { + $results[$channel] = isset($this->users["{$appId}:{$channel}"]) + ? count($this->users["{$appId}:{$channel}"]) + : 0; + + return $results; + }, []); + + return new FulfilledPromise($results); + } + + /** + * Get the channel class by the channel name. + * + * @param string $channelName + * @return string + */ + protected function getChannelClassName(string $channelName): string + { + if (Str::startsWith($channelName, 'private-')) { + return PrivateChannel::class; + } + + if (Str::startsWith($channelName, 'presence-')) { + return PresenceChannel::class; + } + + return Channel::class; + } +} diff --git a/src/ChannelManagers/RedisChannelManager.php b/src/ChannelManagers/RedisChannelManager.php new file mode 100644 index 0000000..eea138c --- /dev/null +++ b/src/ChannelManagers/RedisChannelManager.php @@ -0,0 +1,544 @@ +loop = $loop; + + $connectionUri = $this->getConnectionUri(); + + $factoryClass = $factoryClass ?: Factory::class; + $factory = new $factoryClass($this->loop); + + $this->publishClient = $factory->createLazyClient($connectionUri); + $this->subscribeClient = $factory->createLazyClient($connectionUri); + + $this->subscribeClient->on('message', function ($channel, $payload) { + $this->onMessage($channel, $payload); + }); + + $this->serverId = Str::uuid()->toString(); + } + + /** + * Get all channels for a specific app + * for the current instance. + * + * @param string|int $appId + * @return \React\Promise\PromiseInterface[array] + */ + public function getLocalChannels($appId): PromiseInterface + { + return parent::getLocalChannels($appId); + } + + /** + * Get all channels for a specific app + * across multiple servers. + * + * @param string|int $appId + * @return \React\Promise\PromiseInterface[array] + */ + public function getGlobalChannels($appId): PromiseInterface + { + return $this->getPublishClient()->smembers( + $this->getRedisKey($appId, null, ['channels']) + ); + } + + /** + * Remove connection from all channels. + * + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ + public function unsubscribeFromAllChannels(ConnectionInterface $connection) + { + $this->getGlobalChannels($connection->app->id) + ->then(function ($channels) use ($connection) { + foreach ($channels as $channel) { + $this->unsubscribeFromChannel( + $connection, $channel, new stdClass + ); + } + }); + + parent::unsubscribeFromAllChannels($connection); + } + + /** + * Subscribe the connection to a specific channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param string $channelName + * @param stdClass $payload + * @return void + */ + public function subscribeToChannel(ConnectionInterface $connection, string $channelName, stdClass $payload) + { + $this->getGlobalConnectionsCount($connection->app->id, $channelName) + ->then(function ($count) use ($connection, $channelName) { + if ($count === 0) { + $this->subscribeToTopic($connection->app->id, $channelName); + } + }); + + $this->getPublishClient()->sadd( + $this->getRedisKey($connection->app->id, null, ['channels']), + $channelName + ); + + $this->incrementSubscriptionsCount( + $connection->app->id, $channelName, 1 + ); + + parent::subscribeToChannel($connection, $channelName, $payload); + } + + /** + * Unsubscribe the connection from the channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param string $channelName + * @param stdClass $payload + * @return void + */ + public function unsubscribeFromChannel(ConnectionInterface $connection, string $channelName, stdClass $payload) + { + $this->getGlobalConnectionsCount($connection->app->id, $channelName) + ->then(function ($count) use ($connection, $channelName) { + if ($count === 0) { + $this->unsubscribeFromTopic($connection->app->id, $channelName); + + $this->getPublishClient()->srem( + $this->getRedisKey($connection->app->id, null, ['channels']), + $channelName + ); + + return; + } + + $increment = $this->incrementSubscriptionsCount( + $connection->app->id, $channelName, -1 + ) + ->then(function ($count) use ($connection, $channelName) { + if ($count < 1) { + $this->unsubscribeFromTopic($connection->app->id, $channelName); + + $this->getPublishClient()->srem( + $this->getRedisKey($connection->app->id, null, ['channels']), + $channelName + ); + } + }); + }); + + parent::unsubscribeFromChannel($connection, $channelName, $payload); + } + + /** + * Subscribe the connection to a specific channel. + * + * @param string|int $appId + * @return void + */ + public function subscribeToApp($appId) + { + $this->subscribeToTopic($appId); + + $this->incrementSubscriptionsCount($appId); + } + + /** + * Unsubscribe the connection from the channel. + * + * @param string|int $appId + * @return void + */ + public function unsubscribeFromApp($appId) + { + $this->unsubscribeFromTopic($appId); + + $this->incrementSubscriptionsCount($appId, null, -1); + } + + /** + * Get the connections count on the app + * for the current server instance. + * + * @param string|int $appId + * @param string|null $channelName + * @return \React\Promise\PromiseInterface + */ + public function getLocalConnectionsCount($appId, string $channelName = null): PromiseInterface + { + return parent::getLocalConnectionsCount($appId, $channelName); + } + + /** + * Get the connections count + * across multiple servers. + * + * @param string|int $appId + * @param string|null $channelName + * @return \React\Promise\PromiseInterface + */ + public function getGlobalConnectionsCount($appId, string $channelName = null): PromiseInterface + { + return $this->publishClient + ->hget($this->getRedisKey($appId, $channelName, ['stats']), 'connections') + ->then(function ($count) { + return is_null($count) ? 0 : (int) $count; + }); + } + + /** + * Broadcast the message across multiple servers. + * + * @param string|int $appId + * @param string $channel + * @param stdClass $payload + * @return bool + */ + public function broadcastAcrossServers($appId, string $channel, stdClass $payload) + { + $payload->appId = $appId; + $payload->serverId = $this->getServerId(); + + $this->publishClient->publish($this->getRedisKey($appId, $channel), json_encode($payload)); + + return true; + } + + /** + * Handle the user when it joined a presence channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param stdClass $user + * @param string $channel + * @param stdClass $payload + * @return void + */ + public function userJoinedPresenceChannel(ConnectionInterface $connection, stdClass $user, string $channel, stdClass $payload) + { + $this->storeUserData( + $connection->app->id, $channel, $connection->socketId, json_encode($user) + ); + } + + /** + * Handle the user when it left a presence channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param stdClass $user + * @param string $channel + * @param stdClass $payload + * @return void + */ + public function userLeftPresenceChannel(ConnectionInterface $connection, stdClass $user, string $channel) + { + $this->removeUserData( + $connection->app->id, $channel, $connection->socketId + ); + } + + /** + * Get the presence channel members. + * + * @param string|int $appId + * @param string $channel + * @return \React\Promise\PromiseInterface + */ + public function getChannelMembers($appId, string $channel): PromiseInterface + { + return $this->publishClient + ->hgetall($this->getRedisKey($appId, $channel, ['users'])) + ->then(function ($members) { + [$keys, $values] = collect($members)->partition(function ($value, $key) { + return $key % 2 === 0; + }); + + return collect(array_combine($keys->all(), $values->all())) + ->map(function ($user) { + return json_decode($user); + }) + ->toArray(); + }); + } + + /** + * Get a member from a presence channel based on connection. + * + * @param \Ratchet\ConnectionInterface $connection + * @param string $channel + * @return \React\Promise\PromiseInterface + */ + public function getChannelMember(ConnectionInterface $connection, string $channel): PromiseInterface + { + return $this->publishClient->hget( + $this->getRedisKey($connection->app->id, $channel, ['users']), $connection->socketId + ); + } + + /** + * Get the presence channels total members count. + * + * @param string|int $appId + * @param array $channelNames + * @return \React\Promise\PromiseInterface + */ + public function getChannelsMembersCount($appId, array $channelNames): PromiseInterface + { + $this->publishClient->multi(); + + foreach ($channelNames as $channel) { + $this->publishClient->hlen( + $this->getRedisKey($appId, $channel, ['users']) + ); + } + + return $this->publishClient + ->exec() + ->then(function ($data) use ($channelNames) { + return array_combine($channelNames, $data); + }); + } + + /** + * Handle a message received from Redis on a specific channel. + * + * @param string $redisChannel + * @param string $payload + * @return void + */ + public function onMessage(string $redisChannel, string $payload) + { + $payload = json_decode($payload); + + if (isset($payload->serverId) && $this->getServerId() === $payload->serverId) { + return; + } + + $payload->channel = Str::after($redisChannel, "{$payload->appId}:"); + + if (! $channel = $this->find($payload->appId, $payload->channel)) { + return; + } + + $appId = $payload->appId ?? null; + $socketId = $payload->socketId ?? null; + $serverId = $payload->serverId ?? null; + + unset($payload->socketId); + unset($payload->serverId); + unset($payload->appId); + + $channel->broadcastToEveryoneExcept($payload, $socketId, $appId, false); + } + + /** + * Build the Redis connection URL from Laravel database config. + * + * @return string + */ + protected function getConnectionUri() + { + $name = config('websockets.replication.redis.connection', 'default'); + $config = config("database.redis.{$name}"); + + $host = $config['host']; + $port = $config['port'] ?: 6379; + + $query = []; + + if ($config['password']) { + $query['password'] = $config['password']; + } + + if ($config['database']) { + $query['database'] = $config['database']; + } + + $query = http_build_query($query); + + return "redis://{$host}:{$port}".($query ? "?{$query}" : ''); + } + + /** + * Get the Subscribe client instance. + * + * @return Client + */ + public function getSubscribeClient() + { + return $this->subscribeClient; + } + + /** + * Get the Publish client instance. + * + * @return Client + */ + public function getPublishClient() + { + return $this->publishClient; + } + + /** + * Get the unique identifier for the server. + * + * @return string + */ + public function getServerId() + { + return $this->serverId; + } + + /** + * Increment the subscribed count number. + * + * @param string|int $appId + * @param string|null $channel + * @param int $increment + * @return PromiseInterface + */ + public function incrementSubscriptionsCount($appId, string $channel = null, int $increment = 1) + { + return $this->publishClient->hincrby( + $this->getRedisKey($appId, $channel, ['stats']), 'connections', $increment + ); + } + + /** + * Set data for a topic. Might be used for the presence channels. + * + * @param string|int $appId + * @param string|null $channel + * @param string $key + * @param mixed $data + * @return PromiseInterface + */ + public function storeUserData($appId, string $channel = null, string $key, $data) + { + $this->publishClient->hset( + $this->getRedisKey($appId, $channel, ['users']), $key, $data + ); + } + + /** + * Remove data for a topic. Might be used for the presence channels. + * + * @param string|int $appId + * @param string|null $channel + * @param string $key + * @return PromiseInterface + */ + public function removeUserData($appId, string $channel = null, string $key) + { + return $this->publishClient->hdel( + $this->getRedisKey($appId, $channel), $key + ); + } + + /** + * Subscribe to the topic for the app, or app and channel. + * + * @param string|int $appId + * @param string|null $channel + * @return void + */ + public function subscribeToTopic($appId, string $channel = null) + { + $this->subscribeClient->subscribe( + $this->getRedisKey($appId, $channel) + ); + } + + /** + * Unsubscribe from the topic for the app, or app and channel. + * + * @param string|int $appId + * @param string|null $channel + * @return void + */ + public function unsubscribeFromTopic($appId, string $channel = null) + { + $this->subscribeClient->unsubscribe( + $this->getRedisKey($appId, $channel) + ); + } + + /** + * Get the Redis Keyspace name to handle subscriptions + * and other key-value sets. + * + * @param mixed $appId + * @param string|null $channel + * @return string + */ + public function getRedisKey($appId, string $channel = null, array $suffixes = []): string + { + $prefix = config('database.redis.options.prefix', null); + + $hash = "{$prefix}{$appId}"; + + if ($channel) { + $hash .= ":{$channel}"; + } + + $suffixes = join(':', $suffixes); + + if ($suffixes) { + $hash .= $suffixes; + } + + return $hash; + } +} diff --git a/src/Channels/Channel.php b/src/Channels/Channel.php new file mode 100644 index 0000000..e7e5377 --- /dev/null +++ b/src/Channels/Channel.php @@ -0,0 +1,190 @@ +name = $name; + $this->channelManager = app(ChannelManager::class); + } + + /** + * Get channel name. + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Get the list of subscribed connections. + * + * @return array + */ + public function getConnections() + { + return $this->connections; + } + + /** + * Check if the channel has connections. + * + * @return bool + */ + public function hasConnections(): bool + { + return count($this->getConnections()) > 0; + } + + /** + * Add a new connection to the channel. + * + * @see https://pusher.com/docs/pusher_protocol#presence-channel-events + * @param \Ratchet\ConnectionInterface $connection + * @param \stdClass $payload + * @return void + */ + public function subscribe(ConnectionInterface $connection, stdClass $payload) + { + $this->saveConnection($connection); + + $connection->send(json_encode([ + 'event' => 'pusher_internal:subscription_succeeded', + 'channel' => $this->getName(), + ])); + + DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_SUBSCRIBED, [ + 'socketId' => $connection->socketId, + 'channel' => $this->getName(), + ]); + } + + /** + * Unsubscribe connection from the channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ + public function unsubscribe(ConnectionInterface $connection) + { + if (! isset($this->connections[$connection->socketId])) { + return; + } + + unset($this->connections[$connection->socketId]); + } + + /** + * Store the connection to the subscribers list. + * + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ + protected function saveConnection(ConnectionInterface $connection) + { + $this->connections[$connection->socketId] = $connection; + } + + /** + * Broadcast a payload to the subscribed connections. + * + * @param string|int $appId + * @param \stdClass $payload + * @param bool $replicate + * @return bool + */ + public function broadcast($appId, stdClass $payload, bool $replicate = true): bool + { + collect($this->getConnections()) + ->each->send(json_encode($payload)); + + if ($replicate) { + $this->channelManager->broadcastAcrossServers($appId, $this->getName(), $payload); + } + + return true; + } + + /** + * Broadcast the payload, but exclude a specific socket id. + * + * @param \stdClass $payload + * @param string|null $socketId + * @param string|int $appId + * @param bool $replicate + * @return bool + */ + public function broadcastToEveryoneExcept(stdClass $payload, ?string $socketId, $appId, bool $replicate = true) + { + if ($replicate) { + $this->channelManager->broadcastAcrossServers($appId, $this->getName(), $payload); + } + + if (is_null($socketId)) { + return $this->broadcast($appId, $payload, $replicate); + } + + collect($this->getConnections())->each(function (ConnectionInterface $connection) use ($socketId, $payload) { + if ($connection->socketId !== $socketId) { + $connection->send(json_encode($payload)); + } + }); + + return true; + } + + /** + * Check if the signature for the payload is valid. + * + * @param \Ratchet\ConnectionInterface $connection + * @param \stdClass $payload + * @return void + * @throws InvalidSignature + */ + protected function verifySignature(ConnectionInterface $connection, stdClass $payload) + { + $signature = "{$connection->socketId}:{$this->getName()}"; + + if (isset($payload->channel_data)) { + $signature .= ":{$payload->channel_data}"; + } + + if (! hash_equals( + hash_hmac('sha256', $signature, $connection->app->secret), + Str::after($payload->auth, ':')) + ) { + throw new InvalidSignature; + } + } +} diff --git a/src/Channels/PresenceChannel.php b/src/Channels/PresenceChannel.php new file mode 100644 index 0000000..75808b9 --- /dev/null +++ b/src/Channels/PresenceChannel.php @@ -0,0 +1,139 @@ +channelManager->userJoinedPresenceChannel( + $connection, + $user = json_decode($payload->channel_data), + $this->getName(), + $payload + ); + + $this->channelManager + ->getChannelMembers($connection->app->id, $this->getName()) + ->then(function ($users) use ($connection) { + $connection->send(json_encode([ + 'event' => 'pusher_internal:subscription_succeeded', + 'channel' => $this->getName(), + 'data' => json_encode($this->getChannelData($users)), + ])); + }); + + $memberAddedPayload = [ + 'event' => 'pusher_internal:member_added', + 'channel' => $this->getName(), + 'data' => $payload->channel_data, + ]; + + $this->broadcastToEveryoneExcept( + (object) $memberAddedPayload, $connection->socketId, + $connection->app->id + ); + } + + /** + * Unsubscribe connection from the channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ + public function unsubscribe(ConnectionInterface $connection) + { + parent::unsubscribe($connection); + + $this->channelManager + ->getChannelMember($connection, $this->getName()) + ->then(function ($user) use ($connection) { + $user = @json_decode($user); + + if (! $user) { + return; + } + + $this->channelManager->userLeftPresenceChannel( + $connection, $user, $this->getName() + ); + + $memberRemovedPayload = [ + 'event' => 'pusher_internal:member_removed', + 'channel' => $this->getName(), + 'data' => json_encode([ + 'user_id' => $user->user_id, + ]), + ]; + + $this->broadcastToEveryoneExcept( + (object) $memberRemovedPayload, $connection->socketId, + $connection->app->id + ); + }); + } + + /** + * Get the Presence channel data. + * + * @param array $users + * @return array + */ + protected function getChannelData(array $users): array + { + return [ + 'presence' => [ + 'ids' => $this->getUserIds($users), + 'hash' => $this->getHash($users), + 'count' => count($users), + ], + ]; + } + + /** + * Get the Presence Channel's users. + * + * @param array $users + * @return array + */ + protected function getUserIds(array $users): array + { + return collect($users) + ->map(function ($user) { + return (string) $user->user_id; + }) + ->values(); + } + + /** + * Compute the hash for the presence channel integrity. + * + * @param array $users + * @return array + */ + protected function getHash(array $users): array + { + $hash = []; + + foreach ($users as $socketId => $user) { + $hash[$user->user_id] = $user->user_info ?? []; + } + + return $hash; + } +} diff --git a/src/WebSockets/Channels/PrivateChannel.php b/src/Channels/PrivateChannel.php similarity index 81% rename from src/WebSockets/Channels/PrivateChannel.php rename to src/Channels/PrivateChannel.php index 5f84308..e5d987c 100644 --- a/src/WebSockets/Channels/PrivateChannel.php +++ b/src/Channels/PrivateChannel.php @@ -1,8 +1,8 @@ comment('Cleaning WebSocket Statistics...'); - $amountDeleted = $driver::delete($this->argument('appId')); + $days = $this->option('days') ?: config('statistics.delete_statistics_older_than_days'); - $this->info("Deleted {$amountDeleted} record(s) from the WebSocket statistics."); + $amountDeleted = StatisticsStore::delete( + now()->subDays($days), $this->argument('appId') + ); + + $this->info("Deleted {$amountDeleted} record(s) from the WebSocket statistics storage."); } } diff --git a/src/Console/RestartWebSocketServer.php b/src/Console/Commands/RestartServer.php similarity index 56% rename from src/Console/RestartWebSocketServer.php rename to src/Console/Commands/RestartServer.php index eac1b65..69fe58f 100644 --- a/src/Console/RestartWebSocketServer.php +++ b/src/Console/Commands/RestartServer.php @@ -1,12 +1,12 @@ currentTime()); + Cache::forever( + 'beyondcode:websockets:restart', + $this->currentTime() + ); - $this->info('Broadcasting WebSocket server restart signal.'); + $this->info( + 'Broadcasted the restart signal to the WebSocket server!' + ); } } diff --git a/src/Console/StartWebSocketServer.php b/src/Console/Commands/StartServer.php similarity index 51% rename from src/Console/StartWebSocketServer.php rename to src/Console/Commands/StartServer.php index 0707e05..a088330 100644 --- a/src/Console/StartWebSocketServer.php +++ b/src/Console/Commands/StartServer.php @@ -1,22 +1,20 @@ configureStatisticsLogger() - ->configureHttpLogger() - ->configureMessageLogger() - ->configureConnectionLogger() - ->configureRestartTimer() - ->configurePubSub() - ->registerRoutes() - ->startWebSocketServer(); + $this->configureLoggers(); + + $this->configureManagers(); + + $this->configureStatistics(); + + $this->configureRestartTimer(); + + $this->startServer(); } /** - * Configure the statistics logger class. + * Configure the loggers used for the console. * - * @return $this + * @return void */ - protected function configureStatisticsLogger() + protected function configureLoggers() { - $this->laravel->singleton(StatisticsLoggerInterface::class, function () { - $replicationDriver = config('websockets.replication.driver', 'local'); - - $class = config("websockets.replication.{$replicationDriver}.statistics_logger", \BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger::class); - - return new $class( - $this->laravel->make(ChannelManager::class), - $this->laravel->make(StatisticsDriver::class) - ); - }); - - $this->loop->addPeriodicTimer($this->option('statistics-interval') ?: config('websockets.statistics.interval_in_seconds'), function () { - $this->line('Saving statistics...'); - - StatisticsLogger::save(); - }); - - return $this; + $this->configureHttpLogger(); + $this->configureMessageLogger(); + $this->configureConnectionLogger(); } /** - * Configure the HTTP logger class. + * Register the managers that are not resolved + * in the package service provider. * - * @return $this + * @return void */ - protected function configureHttpLogger() + protected function configureManagers() { - $this->laravel->singleton(HttpLogger::class, function () { - return (new HttpLogger($this->output)) - ->enable($this->option('debug') ?: config('app.debug')) - ->verbose($this->output->isVerbose()); - }); + $this->laravel->singleton(ChannelManager::class, function () { + $mode = config('websockets.replication.mode', 'local'); - return $this; + $class = config("websockets.replication.modes.{$mode}.channel_manager"); + + return new $class($this->loop); + }); } /** - * Configure the logger for messages. + * Register the Statistics Collectors that + * are not resolved in the package service provider. * - * @return $this + * @return void */ - protected function configureMessageLogger() + protected function configureStatistics() { - $this->laravel->singleton(WebsocketsLogger::class, function () { - return (new WebsocketsLogger($this->output)) - ->enable($this->option('debug') ?: config('app.debug')) - ->verbose($this->output->isVerbose()); + $this->laravel->singleton(StatisticsCollector::class, function () { + $replicationMode = config('websockets.replication.mode', 'local'); + + $class = config("websockets.replication.modes.{$replicationMode}.collector"); + + return new $class; }); - return $this; + $this->laravel->singleton(StatisticsStore::class, function () { + $class = config('websockets.statistics.store'); + + return new $class; + }); + + if (! $this->option('disable-statistics')) { + $intervalInSeconds = $this->option('statistics-interval') ?: config('websockets.statistics.interval_in_seconds', 3600); + + $this->loop->addPeriodicTimer($intervalInSeconds, function () { + $this->line('Saving statistics...'); + + StatisticsCollectorFacade::save(); + }); + } } /** - * Configure the connection logger. + * Configure the restart timer. * - * @return $this - */ - protected function configureConnectionLogger() - { - $this->laravel->bind(ConnectionLogger::class, function () { - return (new ConnectionLogger($this->output)) - ->enable(config('app.debug')) - ->verbose($this->output->isVerbose()); - }); - - return $this; - } - - /** - * Configure the Redis PubSub handler. - * - * @return $this + * @return void */ public function configureRestartTimer() { @@ -178,45 +157,48 @@ class StartWebSocketServer extends Command $this->loop->stop(); } }); - - return $this; } /** - * Configure the replicators. + * Configure the HTTP logger class. * * @return void */ - public function configurePubSub() + protected function configureHttpLogger() { - $this->laravel->singleton(ReplicationInterface::class, function () { - $driver = config('websockets.replication.driver', 'local'); - - $client = config( - "websockets.replication.{$driver}.client", - \BeyondCode\LaravelWebSockets\PubSub\Drivers\LocalClient::class - ); - - return (new $client)->boot($this->loop); + $this->laravel->singleton(HttpLogger::class, function () { + return (new HttpLogger($this->output)) + ->enable($this->option('debug') ?: config('app.debug')) + ->verbose($this->output->isVerbose()); }); - - $this->laravel - ->get(ReplicationInterface::class) - ->boot($this->loop); - - return $this; } /** - * Register the routes. + * Configure the logger for messages. * - * @return $this + * @return void */ - protected function registerRoutes() + protected function configureMessageLogger() { - WebSocketsRouter::routes(); + $this->laravel->singleton(WebSocketsLogger::class, function () { + return (new WebSocketsLogger($this->output)) + ->enable($this->option('debug') ?: config('app.debug')) + ->verbose($this->output->isVerbose()); + }); + } - return $this; + /** + * Configure the connection logger. + * + * @return void + */ + protected function configureConnectionLogger() + { + $this->laravel->bind(ConnectionLogger::class, function () { + return (new ConnectionLogger($this->output)) + ->enable(config('app.debug')) + ->verbose($this->output->isVerbose()); + }); } /** @@ -224,7 +206,7 @@ class StartWebSocketServer extends Command * * @return void */ - protected function startWebSocketServer() + protected function startServer() { $this->info("Starting the WebSocket server on port {$this->option('port')}..."); @@ -238,7 +220,6 @@ class StartWebSocketServer extends Command }); } - /* 🛰 Start the server 🛰 */ $this->server->run(); } @@ -249,13 +230,13 @@ class StartWebSocketServer extends Command */ protected function buildServer() { - $this->server = new WebSocketServerFactory( + $this->server = new ServerFactory( $this->option('host'), $this->option('port') ); $this->server = $this->server ->setLoop($this->loop) - ->useRoutes(WebSocketsRouter::getRoutes()) + ->withRoutes(WebSocketsRouter::getRoutes()) ->setConsoleOutput($this->output) ->createServer(); } @@ -267,6 +248,8 @@ class StartWebSocketServer extends Command */ protected function getLastRestart() { - return Cache::get('beyondcode:websockets:restart', 0); + return Cache::get( + 'beyondcode:websockets:restart', 0 + ); } } diff --git a/src/Apps/AppManager.php b/src/Contracts/AppManager.php similarity index 88% rename from src/Apps/AppManager.php rename to src/Contracts/AppManager.php index 86497c0..153eda8 100644 --- a/src/Apps/AppManager.php +++ b/src/Contracts/AppManager.php @@ -1,6 +1,8 @@ header('x-app-id')); + $app = App::findById($request->header('X-App-Id')); $broadcaster = $this->getPusherBroadcaster([ 'key' => $app->key, diff --git a/src/Dashboard/Http/Controllers/SendMessage.php b/src/Dashboard/Http/Controllers/SendMessage.php index ae359ee..e0ac2d6 100644 --- a/src/Dashboard/Http/Controllers/SendMessage.php +++ b/src/Dashboard/Http/Controllers/SendMessage.php @@ -2,51 +2,52 @@ namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers; -use BeyondCode\LaravelWebSockets\Concerns\PushesToPusher; -use BeyondCode\LaravelWebSockets\Statistics\Rules\AppId; -use Exception; +use BeyondCode\LaravelWebSockets\Contracts\ChannelManager; +use BeyondCode\LaravelWebSockets\Rules\AppId; use Illuminate\Http\Request; class SendMessage { - use PushesToPusher; - /** * Send the message to the requested channel. * * @param \Illuminate\Http\Request $request + * @param \BeyondCode\LaravelWebSockets\Contracts\ChannelManager $channelManager * @return \Illuminate\Http\Response */ - public function __invoke(Request $request) + public function __invoke(Request $request, ChannelManager $channelManager) { $request->validate([ 'appId' => ['required', new AppId], - 'key' => 'required|string', - 'secret' => 'required|string', 'channel' => 'required|string', 'event' => 'required|string', 'data' => 'required|json', ]); - $broadcaster = $this->getPusherBroadcaster([ - 'key' => $request->key, - 'secret' => $request->secret, - 'id' => $request->appId, - ]); + $payload = [ + 'channel' => $request->channel, + 'event' => $request->event, + 'data' => json_decode($request->data, true), + ]; - try { - $decodedData = @json_decode($request->data, true); + // Here you can use the ->find(), even if the channel + // does not exist on the server. If it does not exist, + // then the message simply will get broadcasted + // across the other servers. + $channel = $channelManager->find( + $request->appId, $request->channel + ); - $broadcaster->broadcast( - [$request->channel], - $request->event, - $decodedData ?: [] + if ($channel) { + $channel->broadcastToEveryoneExcept( + (object) $payload, + null, + $request->appId + ); + } else { + $channelManager->broadcastAcrossServers( + $request->appId, $request->channel, (object) $payload ); - } catch (Exception $e) { - return response()->json([ - 'ok' => false, - 'exception' => $e->getMessage(), - ]); } return response()->json([ diff --git a/src/Dashboard/Http/Controllers/ShowDashboard.php b/src/Dashboard/Http/Controllers/ShowDashboard.php index f6dc6b1..eabd22d 100644 --- a/src/Dashboard/Http/Controllers/ShowDashboard.php +++ b/src/Dashboard/Http/Controllers/ShowDashboard.php @@ -2,8 +2,8 @@ namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers; -use BeyondCode\LaravelWebSockets\Apps\AppManager; -use BeyondCode\LaravelWebSockets\Dashboard\DashboardLogger; +use BeyondCode\LaravelWebSockets\Contracts\AppManager; +use BeyondCode\LaravelWebSockets\DashboardLogger; use Illuminate\Http\Request; class ShowDashboard @@ -12,7 +12,7 @@ class ShowDashboard * Show the dashboard. * * @param \Illuminate\Http\Request $request - * @param \BeyondCode\LaravelWebSockets\Apps\AppManager $apps + * @param \BeyondCode\LaravelWebSockets\Contracts\AppManager $apps * @return void */ public function __invoke(Request $request, AppManager $apps) diff --git a/src/Dashboard/Http/Controllers/ShowStatistics.php b/src/Dashboard/Http/Controllers/ShowStatistics.php index 134cb62..cec51c6 100644 --- a/src/Dashboard/Http/Controllers/ShowStatistics.php +++ b/src/Dashboard/Http/Controllers/ShowStatistics.php @@ -2,7 +2,7 @@ namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers; -use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver; +use BeyondCode\LaravelWebSockets\Facades\StatisticsStore; use Illuminate\Http\Request; class ShowStatistics @@ -11,12 +11,23 @@ class ShowStatistics * Get statistics for an app ID. * * @param \Illuminate\Http\Request $request - * @param \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver $driver * @param mixed $appId * @return \Illuminate\Http\Response */ - public function __invoke(Request $request, StatisticsDriver $driver, $appId) + public function __invoke(Request $request, $appId) { - return $driver::get($appId, $request); + $processQuery = function ($query) use ($appId) { + return $query->whereAppId($appId) + ->latest() + ->limit(120); + }; + + $processCollection = function ($collection) { + return $collection->reverse(); + }; + + return StatisticsStore::getForGraph( + $processQuery, $processCollection + ); } } diff --git a/src/Dashboard/DashboardLogger.php b/src/DashboardLogger.php similarity index 70% rename from src/Dashboard/DashboardLogger.php rename to src/DashboardLogger.php index 70397ce..046d6ff 100644 --- a/src/Dashboard/DashboardLogger.php +++ b/src/DashboardLogger.php @@ -1,8 +1,8 @@ find($appId, $channelName); - - optional($channel)->broadcast([ - 'event' => 'log-message', + $payload = [ 'channel' => $channelName, + 'event' => 'log-message', 'data' => [ 'type' => $type, 'time' => strftime('%H:%M:%S'), 'details' => $details, ], - ]); + ]; + + // Here you can use the ->find(), even if the channel + // does not exist on the server. If it does not exist, + // then the message simply will get broadcasted + // across the other servers. + $channel = $channelManager->find($appId, $channelName); + + if ($channel) { + $channel->broadcastToEveryoneExcept( + (object) $payload, + null, + $appId + ); + } else { + $channelManager->broadcastAcrossServers( + $appId, $channelName, (object) $payload + ); + } } } diff --git a/src/Events/MessagesBroadcasted.php b/src/Events/MessagesBroadcasted.php deleted file mode 100644 index 5f78870..0000000 --- a/src/Events/MessagesBroadcasted.php +++ /dev/null @@ -1,29 +0,0 @@ -sentMessagesCount = $sentMessagesCount; - } -} diff --git a/src/Events/Subscribed.php b/src/Events/Subscribed.php deleted file mode 100644 index 9bdae48..0000000 --- a/src/Events/Subscribed.php +++ /dev/null @@ -1,39 +0,0 @@ -channelName = $channelName; - $this->connection = $connection; - } -} diff --git a/src/Events/Unsubscribed.php b/src/Events/Unsubscribed.php deleted file mode 100644 index 66c412a..0000000 --- a/src/Events/Unsubscribed.php +++ /dev/null @@ -1,39 +0,0 @@ -channelName = $channelName; - $this->connection = $connection; - } -} diff --git a/src/Facades/StatisticsCollector.php b/src/Facades/StatisticsCollector.php new file mode 100644 index 0000000..7878831 --- /dev/null +++ b/src/Facades/StatisticsCollector.php @@ -0,0 +1,19 @@ +channelManager->find($request->appId, $request->channelName); - - if (is_null($channel)) { - throw new HttpException(404, "Unknown channel `{$request->channelName}`."); - } - - return $channel->toArray($request->appId); - } -} diff --git a/src/HttpApi/Controllers/FetchChannelsController.php b/src/HttpApi/Controllers/FetchChannelsController.php deleted file mode 100644 index bb0d24e..0000000 --- a/src/HttpApi/Controllers/FetchChannelsController.php +++ /dev/null @@ -1,67 +0,0 @@ -has('info')) { - $attributes = explode(',', trim($request->info)); - - if (in_array('user_count', $attributes) && ! Str::startsWith($request->filter_by_prefix, 'presence-')) { - throw new HttpException(400, 'Request must be limited to presence channels in order to fetch user_count'); - } - } - - $channels = Collection::make($this->channelManager->getChannels($request->appId)); - - if ($request->has('filter_by_prefix')) { - $channels = $channels->filter(function ($channel, $channelName) use ($request) { - return Str::startsWith($channelName, $request->filter_by_prefix); - }); - } - - // We want to get the channel user count all in one shot when - // using a replication backend rather than doing individual queries. - // To do so, we first collect the list of channel names. - $channelNames = $channels->map(function (PresenceChannel $channel) { - return $channel->getChannelName(); - })->toArray(); - - // We ask the replication backend to get us the member count per channel. - // We get $counts back as a key-value array of channel names and their member count. - return $this->replicator - ->channelMemberCounts($request->appId, $channelNames) - ->then(function (array $counts) use ($channels, $attributes) { - $channels = $channels->map(function (PresenceChannel $channel) use ($counts, $attributes) { - $info = new stdClass; - - if (in_array('user_count', $attributes)) { - $info->user_count = $counts[$channel->getChannelName()]; - } - - return $info; - })->toArray(); - - return [ - 'channels' => $channels ?: new stdClass, - ]; - }); - } -} diff --git a/src/HttpApi/Controllers/FetchUsersController.php b/src/HttpApi/Controllers/FetchUsersController.php deleted file mode 100644 index 25acee9..0000000 --- a/src/HttpApi/Controllers/FetchUsersController.php +++ /dev/null @@ -1,40 +0,0 @@ -channelManager->find($request->appId, $request->channelName); - - if (is_null($channel)) { - throw new HttpException(404, 'Unknown channel "'.$request->channelName.'"'); - } - - if (! $channel instanceof PresenceChannel) { - throw new HttpException(400, 'Invalid presence channel "'.$request->channelName.'"'); - } - - return $channel - ->getUsers($request->appId) - ->then(function (array $users) { - return [ - 'users' => Collection::make($users)->map(function ($user) { - return ['id' => $user->user_id]; - })->values(), - ]; - }); - } -} diff --git a/src/HttpApi/Controllers/TriggerEventController.php b/src/HttpApi/Controllers/TriggerEventController.php deleted file mode 100644 index 9dc3b7d..0000000 --- a/src/HttpApi/Controllers/TriggerEventController.php +++ /dev/null @@ -1,57 +0,0 @@ -ensureValidSignature($request); - - $channels = $request->channels ?: []; - - foreach ($channels as $channelName) { - $channel = $this->channelManager->find($request->appId, $channelName); - - $payload = (object) [ - 'channel' => $channelName, - 'event' => $request->name, - 'data' => $request->data, - ]; - - if ($channel) { - $channel->broadcastToEveryoneExcept( - $payload, $request->socket_id, $request->appId - ); - } else { - // If the setup is horizontally-scaled using the Redis Pub/Sub, - // then we're going to make sure it gets streamed to the other - // servers as well that are subscribed to the Pub/Sub topics - // attached to the current iterated app & channel. - // For local setups, the local driver will ignore the publishes. - - $this->replicator->publish($request->appId, $channelName, $payload); - } - - DashboardLogger::log($request->appId, DashboardLogger::TYPE_API_MESSAGE, [ - 'channel' => $channelName, - 'event' => $request->json()->get('name'), - 'payload' => $request->json()->get('data'), - ]); - - StatisticsLogger::apiMessage($request->appId); - } - - return $request->json()->all(); - } -} diff --git a/src/Statistics/Models/WebSocketsStatisticsEntry.php b/src/Models/WebSocketsStatisticsEntry.php similarity index 81% rename from src/Statistics/Models/WebSocketsStatisticsEntry.php rename to src/Models/WebSocketsStatisticsEntry.php index edd0de1..e1d0d6b 100644 --- a/src/Statistics/Models/WebSocketsStatisticsEntry.php +++ b/src/Models/WebSocketsStatisticsEntry.php @@ -1,6 +1,6 @@ channelData["{$appId}:{$channel}"][$socketId] = $data; - } - - /** - * Remove a member from the channel. To be called when they have - * unsubscribed from the channel. - * - * @param string $appId - * @param string $channel - * @param string $socketId - * @return void - */ - public function leaveChannel($appId, string $channel, string $socketId) - { - unset($this->channelData["{$appId}:{$channel}"][$socketId]); - - if (empty($this->channelData["{$appId}:{$channel}"])) { - unset($this->channelData["{$appId}:{$channel}"]); - } - } - - /** - * Retrieve the full information about the members in a presence channel. - * - * @param string $appId - * @param string $channel - * @return PromiseInterface - */ - public function channelMembers($appId, string $channel): PromiseInterface - { - $members = $this->channelData["{$appId}:{$channel}"] ?? []; - - $members = array_map(function ($user) { - return json_decode($user); - }, $members); - - return new FulfilledPromise($members); - } - - /** - * Get the amount of users subscribed for each presence channel. - * - * @param string $appId - * @param array $channelNames - * @return PromiseInterface - */ - public function channelMemberCounts($appId, array $channelNames): PromiseInterface - { - $results = []; - - // Count the number of users per channel - foreach ($channelNames as $channel) { - $results[$channel] = isset($this->channelData["{$appId}:{$channel}"]) - ? count($this->channelData["{$appId}:{$channel}"]) - : 0; - } - - return new FulfilledPromise($results); - } - - /** - * Get the amount of unique connections. - * - * @param mixed $appId - * @return null|int - */ - public function getLocalConnectionsCount($appId) - { - return null; - } - - /** - * Get the amount of connections aggregated on multiple instances. - * - * @param mixed $appId - * @return null|int|\React\Promise\PromiseInterface - */ - public function getGlobalConnectionsCount($appId) - { - return null; - } -} diff --git a/src/PubSub/Drivers/RedisClient.php b/src/PubSub/Drivers/RedisClient.php deleted file mode 100644 index 78acef4..0000000 --- a/src/PubSub/Drivers/RedisClient.php +++ /dev/null @@ -1,437 +0,0 @@ -serverId = Str::uuid()->toString(); - } - - /** - * Boot the RedisClient, initializing the connections. - * - * @param LoopInterface $loop - * @param string|null $factoryClass - * @return ReplicationInterface - */ - public function boot(LoopInterface $loop, $factoryClass = null): ReplicationInterface - { - $factoryClass = $factoryClass ?: Factory::class; - - $this->loop = $loop; - - $connectionUri = $this->getConnectionUri(); - $factory = new $factoryClass($this->loop); - - $this->publishClient = $factory->createLazyClient($connectionUri); - $this->subscribeClient = $factory->createLazyClient($connectionUri); - - // The subscribed client gets a message, it triggers the onMessage(). - $this->subscribeClient->on('message', function ($channel, $payload) { - $this->onMessage($channel, $payload); - }); - - return $this; - } - - /** - * Publish a message to a channel on behalf of a websocket user. - * - * @param string $appId - * @param string $channel - * @param stdClass $payload - * @return bool - */ - public function publish($appId, string $channel, stdClass $payload): bool - { - $payload->appId = $appId; - $payload->serverId = $this->getServerId(); - - $payload = json_encode($payload); - - $this->publishClient->publish($this->getTopicName($appId, $channel), $payload); - - DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_MESSAGE_PUBLISHED, [ - 'channel' => $channel, - 'serverId' => $this->getServerId(), - 'payload' => $payload, - 'pubsub' => $this->getTopicName($appId, $channel), - ]); - - return true; - } - - /** - * Subscribe to a channel on behalf of websocket user. - * - * @param string $appId - * @param string $channel - * @return bool - */ - public function subscribe($appId, string $channel): bool - { - if (! isset($this->subscribedChannels["{$appId}:{$channel}"])) { - // We're not subscribed to the channel yet, subscribe and set the count to 1 - $this->subscribeClient->subscribe($this->getTopicName($appId, $channel)); - $this->subscribedChannels["{$appId}:{$channel}"] = 1; - } else { - // Increment the subscribe count if we've already subscribed - $this->subscribedChannels["{$appId}:{$channel}"]++; - } - - DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_SUBSCRIBED, [ - 'channel' => $channel, - 'serverId' => $this->getServerId(), - 'pubsub' => $this->getTopicName($appId, $channel), - ]); - - return true; - } - - /** - * Unsubscribe from a channel on behalf of a websocket user. - * - * @param string $appId - * @param string $channel - * @return bool - */ - public function unsubscribe($appId, string $channel): bool - { - if (! isset($this->subscribedChannels["{$appId}:{$channel}"])) { - return false; - } - - // Decrement the subscription count for this channel - $this->subscribedChannels["{$appId}:{$channel}"]--; - - // If we no longer have subscriptions to that channel, unsubscribe - if ($this->subscribedChannels["{$appId}:{$channel}"] < 1) { - $this->subscribeClient->unsubscribe($this->getTopicName($appId, $channel)); - - unset($this->subscribedChannels["{$appId}:{$channel}"]); - } - - DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_UNSUBSCRIBED, [ - 'channel' => $channel, - 'serverId' => $this->getServerId(), - 'pubsub' => $this->getTopicName($appId, $channel), - ]); - - return true; - } - - /** - * Subscribe to the app's pubsub keyspace. - * - * @param mixed $appId - * @return bool - */ - public function subscribeToApp($appId): bool - { - $this->subscribeClient->subscribe($this->getTopicName($appId)); - - $this->publishClient->hincrby($this->getTopicName($appId), 'connections', 1); - - return true; - } - - /** - * Unsubscribe from the app's pubsub keyspace. - * - * @param mixed $appId - * @return bool - */ - public function unsubscribeFromApp($appId): bool - { - $this->subscribeClient->unsubscribe($this->getTopicName($appId)); - - $this->publishClient->hincrby($this->getTopicName($appId), 'connections', -1); - - return true; - } - - /** - * Add a member to a channel. To be called when they have - * subscribed to the channel. - * - * @param string $appId - * @param string $channel - * @param string $socketId - * @param string $data - * @return void - */ - public function joinChannel($appId, string $channel, string $socketId, string $data) - { - $this->publishClient->hset($this->getTopicName($appId, $channel), $socketId, $data); - - DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_JOINED_CHANNEL, [ - 'channel' => $channel, - 'serverId' => $this->getServerId(), - 'socketId' => $socketId, - 'data' => $data, - 'pubsub' => $this->getTopicName($appId, $channel), - ]); - } - - /** - * Remove a member from the channel. To be called when they have - * unsubscribed from the channel. - * - * @param string $appId - * @param string $channel - * @param string $socketId - * @return void - */ - public function leaveChannel($appId, string $channel, string $socketId) - { - $this->publishClient->hdel($this->getTopicName($appId, $channel), $socketId); - - DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_LEFT_CHANNEL, [ - 'channel' => $channel, - 'serverId' => $this->getServerId(), - 'socketId' => $socketId, - 'pubsub' => $this->getTopicName($appId, $channel), - ]); - } - - /** - * Retrieve the full information about the members in a presence channel. - * - * @param string $appId - * @param string $channel - * @return PromiseInterface - */ - public function channelMembers($appId, string $channel): PromiseInterface - { - return $this->publishClient->hgetall($this->getTopicName($appId, $channel)) - ->then(function ($members) { - // The data is expected as objects, so we need to JSON decode - return array_map(function ($user) { - return json_decode($user); - }, $members); - }); - } - - /** - * Get the amount of users subscribed for each presence channel. - * - * @param string $appId - * @param array $channelNames - * @return PromiseInterface - */ - public function channelMemberCounts($appId, array $channelNames): PromiseInterface - { - $this->publishClient->multi(); - - foreach ($channelNames as $channel) { - $this->publishClient->hlen($this->getTopicName($appId, $channel)); - } - - return $this->publishClient - ->exec() - ->then(function ($data) use ($channelNames) { - return array_combine($channelNames, $data); - }); - } - - /** - * Get the amount of connections aggregated on multiple instances. - * - * @param mixed $appId - * @return null|int|\React\Promise\PromiseInterface - */ - public function getGlobalConnectionsCount($appId) - { - return $this->publishClient->hget($this->getTopicName($appId), 'connections'); - } - - /** - * Handle a message received from Redis on a specific channel. - * - * @param string $redisChannel - * @param string $payload - * @return void - */ - public function onMessage(string $redisChannel, string $payload) - { - $payload = json_decode($payload); - - // Ignore messages sent by ourselves. - if (isset($payload->serverId) && $this->getServerId() === $payload->serverId) { - return; - } - - // Pull out the app ID. See RedisPusherBroadcaster - $appId = $payload->appId; - - // We need to put the channel name in the payload. - // We strip the app ID from the channel name, websocket clients - // expect the channel name to not include the app ID. - $payload->channel = Str::after($redisChannel, "{$appId}:"); - - $channelManager = app(ChannelManager::class); - - // Load the Channel instance to sync. - $channel = $channelManager->find($appId, $payload->channel); - - // If no channel is found, none of our connections want to - // receive this message, so we ignore it. - if (! $channel) { - return; - } - - $socketId = $payload->socketId ?? null; - $serverId = $payload->serverId ?? null; - - // Remove fields intended for internal use from the payload. - unset($payload->socketId); - unset($payload->serverId); - unset($payload->appId); - - // Push the message out to connected websocket clients. - $channel->broadcastToEveryoneExcept($payload, $socketId, $appId, false); - - DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_MESSAGE_RECEIVED, [ - 'channel' => $channel->getChannelName(), - 'redisChannel' => $redisChannel, - 'serverId' => $this->getServerId(), - 'incomingServerId' => $serverId, - 'incomingSocketId' => $socketId, - 'payload' => $payload, - ]); - } - - /** - * Build the Redis connection URL from Laravel database config. - * - * @return string - */ - protected function getConnectionUri() - { - $name = config('websockets.replication.redis.connection', 'default'); - $config = config("database.redis.{$name}"); - - $host = $config['host']; - $port = $config['port'] ?: 6379; - - $query = []; - - if ($config['password']) { - $query['password'] = $config['password']; - } - - if ($config['database']) { - $query['database'] = $config['database']; - } - - $query = http_build_query($query); - - return "redis://{$host}:{$port}".($query ? "?{$query}" : ''); - } - - /** - * Get the Subscribe client instance. - * - * @return Client - */ - public function getSubscribeClient() - { - return $this->subscribeClient; - } - - /** - * Get the Publish client instance. - * - * @return Client - */ - public function getPublishClient() - { - return $this->publishClient; - } - - /** - * Get the unique identifier for the server. - * - * @return string - */ - public function getServerId() - { - return $this->serverId; - } - - /** - * Get the Pub/Sub Topic name to subscribe based on the - * app ID and channel name. - * - * @param mixed $appId - * @param string|null $channel - * @return string - */ - protected function getTopicName($appId, string $channel = null): string - { - $prefix = config('database.redis.options.prefix', null); - - $hash = "{$prefix}{$appId}"; - - if ($channel) { - $hash .= ":{$channel}"; - } - - return $hash; - } -} diff --git a/src/PubSub/ReplicationInterface.php b/src/PubSub/ReplicationInterface.php deleted file mode 100644 index 5ca3ee3..0000000 --- a/src/PubSub/ReplicationInterface.php +++ /dev/null @@ -1,120 +0,0 @@ -message = 'Over capacity'; - $this->code = 4100; + $this->trigger('Over capacity', 4100); } } diff --git a/src/WebSockets/Exceptions/InvalidSignature.php b/src/Server/Exceptions/InvalidSignature.php similarity index 64% rename from src/WebSockets/Exceptions/InvalidSignature.php rename to src/Server/Exceptions/InvalidSignature.php index b0229b3..b2aaf79 100644 --- a/src/WebSockets/Exceptions/InvalidSignature.php +++ b/src/Server/Exceptions/InvalidSignature.php @@ -1,6 +1,6 @@ message = 'Invalid Signature'; - $this->code = 4009; + $this->trigger('Invalid Signature', 4009); } } diff --git a/src/Server/Exceptions/OriginNotAllowed.php b/src/Server/Exceptions/OriginNotAllowed.php new file mode 100644 index 0000000..cd24fff --- /dev/null +++ b/src/Server/Exceptions/OriginNotAllowed.php @@ -0,0 +1,17 @@ +trigger("The origin is not allowed for `{$appKey}`.", 4009); + } +} diff --git a/src/Server/Exceptions/UnknownAppKey.php b/src/Server/Exceptions/UnknownAppKey.php new file mode 100644 index 0000000..013d9be --- /dev/null +++ b/src/Server/Exceptions/UnknownAppKey.php @@ -0,0 +1,17 @@ +trigger("Could not find app key `{$appKey}`.", 4001); + } +} diff --git a/src/WebSockets/Exceptions/WebSocketException.php b/src/Server/Exceptions/WebSocketException.php similarity index 54% rename from src/WebSockets/Exceptions/WebSocketException.php rename to src/Server/Exceptions/WebSocketException.php index d38da70..cc7cbf9 100644 --- a/src/WebSockets/Exceptions/WebSocketException.php +++ b/src/Server/Exceptions/WebSocketException.php @@ -1,6 +1,6 @@ message = $message; + $this->code = $code; + } } diff --git a/src/Server/HttpServer.php b/src/Server/HttpServer.php index b497d34..a9f4d0c 100644 --- a/src/Server/HttpServer.php +++ b/src/Server/HttpServer.php @@ -2,9 +2,10 @@ namespace BeyondCode\LaravelWebSockets\Server; +use Ratchet\Http\HttpServer as BaseHttpServer; use Ratchet\Http\HttpServerInterface; -class HttpServer extends \Ratchet\Http\HttpServer +class HttpServer extends BaseHttpServer { /** * Create a new server instance. diff --git a/src/Server/Logger/ConnectionLogger.php b/src/Server/Loggers/ConnectionLogger.php similarity index 95% rename from src/Server/Logger/ConnectionLogger.php rename to src/Server/Loggers/ConnectionLogger.php index 4a1b02d..60e2ffb 100644 --- a/src/Server/Logger/ConnectionLogger.php +++ b/src/Server/Loggers/ConnectionLogger.php @@ -1,6 +1,6 @@ enabled; + $logger = app(WebSocketsLogger::class); + + return $logger->enabled; } /** diff --git a/src/Server/Logger/WebsocketsLogger.php b/src/Server/Loggers/WebSocketsLogger.php similarity index 94% rename from src/Server/Logger/WebsocketsLogger.php rename to src/Server/Loggers/WebSocketsLogger.php index cb68f20..a9555e1 100644 --- a/src/Server/Logger/WebsocketsLogger.php +++ b/src/Server/Loggers/WebSocketsLogger.php @@ -1,14 +1,14 @@ payload = $payload; - - $this->connection = $connection; - - $this->channelManager = $channelManager; - } - /** * Respond with the payload. * @@ -84,9 +46,7 @@ class PusherChannelProtocolMessage implements PusherMessage */ protected function subscribe(ConnectionInterface $connection, stdClass $payload) { - $channel = $this->channelManager->findOrCreate($connection->app->id, $payload->channel); - - $channel->subscribe($connection, $payload); + $this->channelManager->subscribeToChannel($connection, $payload->channel, $payload); } /** @@ -98,8 +58,6 @@ class PusherChannelProtocolMessage implements PusherMessage */ public function unsubscribe(ConnectionInterface $connection, stdClass $payload) { - $channel = $this->channelManager->findOrCreate($connection->app->id, $payload->channel); - - $channel->unsubscribe($connection); + $this->channelManager->unsubscribeFromChannel($connection, $payload->channel, $payload); } } diff --git a/src/WebSockets/Messages/PusherClientMessage.php b/src/Server/Messages/PusherClientMessage.php similarity index 75% rename from src/WebSockets/Messages/PusherClientMessage.php rename to src/Server/Messages/PusherClientMessage.php index cab08d1..afb74dc 100644 --- a/src/WebSockets/Messages/PusherClientMessage.php +++ b/src/Server/Messages/PusherClientMessage.php @@ -1,9 +1,10 @@ channelManager->find( + $this->connection->app->id, $this->payload->channel + ); + + optional($channel)->broadcastToEveryoneExcept( + $this->payload, $this->connection->socketId, $this->connection->app->id + ); + DashboardLogger::log($this->connection->app->id, DashboardLogger::TYPE_WS_MESSAGE, [ 'socketId' => $this->connection->socketId, 'channel' => $this->payload->channel, 'event' => $this->payload->event, 'data' => $this->payload, ]); - - $channel = $this->channelManager->find($this->connection->app->id, $this->payload->channel); - - optional($channel)->broadcastToOthers($this->connection, $this->payload); } } diff --git a/src/WebSockets/Messages/PusherMessageFactory.php b/src/Server/Messages/PusherMessageFactory.php similarity index 76% rename from src/WebSockets/Messages/PusherMessageFactory.php rename to src/Server/Messages/PusherMessageFactory.php index 0136449..253252b 100644 --- a/src/WebSockets/Messages/PusherMessageFactory.php +++ b/src/Server/Messages/PusherMessageFactory.php @@ -1,8 +1,9 @@ routes = new RouteCollection; - $this->customRoutes = new Collection(); } /** @@ -53,22 +38,17 @@ class Router } /** - * Register the routes. + * Register the default routes. * * @return void */ public function routes() { - $this->get('/app/{appKey}', config('websockets.handlers.websocket', WebSocketHandler::class)); - - $this->post('/apps/{appId}/events', config('websockets.handlers.trigger_event', TriggerEventController::class)); - $this->get('/apps/{appId}/channels', config('websockets.handlers.fetch_channels', FetchChannelsController::class)); - $this->get('/apps/{appId}/channels/{channelName}', config('websockets.handlers.fetch_channel', FetchChannelController::class)); - $this->get('/apps/{appId}/channels/{channelName}/users', config('websockets.handlers.fetch_users', FetchUsersController::class)); - - $this->customRoutes->each(function ($action, $uri) { - $this->get($uri, $action); - }); + $this->get('/app/{appKey}', config('websockets.handlers.websocket')); + $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')); + $this->get('/apps/{appId}/channels/{channelName}/users', config('websockets.handlers.fetch_users')); } /** @@ -131,23 +111,6 @@ class Router $this->addRoute('DELETE', $uri, $action); } - /** - * Add a WebSocket GET route that should - * comply with the MessageComponentInterface interface. - * - * @param string $uri - * @param string $action - * @return void - */ - public function webSocket(string $uri, $action) - { - if (! is_subclass_of($action, MessageComponentInterface::class)) { - throw InvalidWebSocketController::withController($action); - } - - $this->customRoutes->put($uri, $action); - } - /** * Add a new route to the list. * @@ -171,12 +134,6 @@ class Router */ protected function getRoute(string $method, string $uri, $action): Route { - /** - * If the given action is a class that handles WebSockets, then it's not a regular - * controller but a WebSocketHandler that needs to converted to a WsServer. - * - * If the given action is a regular controller we'll just instantiate it. - */ $action = is_subclass_of($action, MessageComponentInterface::class) ? $this->createWebSocketsServer($action) : app($action); diff --git a/src/WebSockets/WebSocketHandler.php b/src/Server/WebSocketHandler.php similarity index 50% rename from src/WebSockets/WebSocketHandler.php rename to src/Server/WebSocketHandler.php index 8b363d2..1016a1a 100644 --- a/src/WebSockets/WebSocketHandler.php +++ b/src/Server/WebSocketHandler.php @@ -1,50 +1,34 @@ channelManager = $channelManager; - $this->replicator = app(ReplicationInterface::class); } /** @@ -60,6 +44,20 @@ class WebSocketHandler implements MessageComponentInterface ->limitConcurrentConnections($connection) ->generateSocketId($connection) ->establishConnection($connection); + + if (isset($connection->app)) { + /** @var \GuzzleHttp\Psr7\Request $request */ + $request = $connection->httpRequest; + + StatisticsCollector::connection($connection->app->id); + + $this->channelManager->subscribeToApp($connection->app->id); + + DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_CONNECTED, [ + 'origin' => "{$request->getUri()->getScheme()}://{$request->getUri()->getHost()}", + 'socketId' => $connection->socketId, + ]); + } } /** @@ -71,11 +69,11 @@ class WebSocketHandler implements MessageComponentInterface */ public function onMessage(ConnectionInterface $connection, MessageInterface $message) { - $message = PusherMessageFactory::createForMessage($message, $connection, $this->channelManager); + Messages\PusherMessageFactory::createForMessage( + $message, $connection, $this->channelManager + )->respond(); - $message->respond(); - - StatisticsLogger::webSocketMessage($connection->app->id); + StatisticsCollector::webSocketMessage($connection->app->id); } /** @@ -86,15 +84,17 @@ class WebSocketHandler implements MessageComponentInterface */ public function onClose(ConnectionInterface $connection) { - $this->channelManager->removeFromAllChannels($connection); + $this->channelManager->unsubscribeFromAllChannels($connection); - DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_DISCONNECTED, [ - 'socketId' => $connection->socketId, - ]); + if (isset($connection->app)) { + StatisticsCollector::disconnection($connection->app->id); - StatisticsLogger::disconnection($connection->app->id); + $this->channelManager->unsubscribeFromApp($connection->app->id); - $this->replicator->unsubscribeFromApp($connection->app->id); + DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_DISCONNECTED, [ + 'socketId' => $connection->socketId, + ]); + } } /** @@ -106,13 +106,11 @@ class WebSocketHandler implements MessageComponentInterface */ public function onError(ConnectionInterface $connection, Exception $exception) { - if ($exception instanceof WebSocketException) { + if ($exception instanceof Exceptions\WebSocketException) { $connection->send(json_encode( $exception->getPayload() )); } - - $this->replicator->unsubscribeFromApp($connection->app->id); } /** @@ -123,10 +121,12 @@ class WebSocketHandler implements MessageComponentInterface */ protected function verifyAppKey(ConnectionInterface $connection) { - $appKey = QueryParameters::create($connection->httpRequest)->get('appKey'); + $query = QueryParameters::create($connection->httpRequest); + + $appKey = $query->get('appKey'); if (! $app = App::findByKey($appKey)) { - throw new UnknownAppKey($appKey); + throw new Exceptions\UnknownAppKey($appKey); } $connection->app = $app; @@ -151,7 +151,7 @@ class WebSocketHandler implements MessageComponentInterface $origin = parse_url($header, PHP_URL_HOST) ?: $header; if (! $header || ! in_array($origin, $connection->app->allowedOrigins)) { - throw new OriginNotAllowed($connection->app->key); + throw new Exceptions\OriginNotAllowed($connection->app->key); } return $this; @@ -166,17 +166,17 @@ class WebSocketHandler implements MessageComponentInterface protected function limitConcurrentConnections(ConnectionInterface $connection) { if (! is_null($capacity = $connection->app->capacity)) { - $connectionsCount = $this->channelManager->getGlobalConnectionsCount($connection->app->id); + $this->channelManager + ->getGlobalConnectionsCount($connection->app->id) + ->then(function ($connectionsCount) use ($capacity, $connection) { + if ($connectionsCount >= $capacity) { + $exception = new Exceptions\ConnectionsOverCapacity; - if ($connectionsCount instanceof PromiseInterface) { - $connectionsCount->then(function ($connectionsCount) use ($capacity, $connection) { - $connectionsCount = $connectionsCount ?: 0; + $payload = json_encode($exception->getPayload()); - $this->sendExceptionIfOverCapacity($connectionsCount, $capacity, $connection); + tap($connection)->send($payload)->close(); + } }); - } else { - $this->throwExceptionIfOverCapacity($connectionsCount, $capacity); - } } return $this; @@ -213,51 +213,6 @@ class WebSocketHandler implements MessageComponentInterface ]), ])); - /** @var \GuzzleHttp\Psr7\Request $request */ - $request = $connection->httpRequest; - - DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_CONNECTED, [ - 'origin' => "{$request->getUri()->getScheme()}://{$request->getUri()->getHost()}", - 'socketId' => $connection->socketId, - ]); - - StatisticsLogger::connection($connection->app->id); - - $this->replicator->subscribeToApp($connection->app->id); - return $this; } - - /** - * Throw a ConnectionsOverCapacity exception. - * - * @param int $connectionsCount - * @param int $capacity - * @return void - * @throws ConnectionsOverCapacity - */ - protected function throwExceptionIfOverCapacity(int $connectionsCount, int $capacity) - { - if ($connectionsCount >= $capacity) { - throw new ConnectionsOverCapacity; - } - } - - /** - * Send the ConnectionsOverCapacity exception through - * the connection and close the channel. - * - * @param int $connectionsCount - * @param int $capacity - * @param ConnectionInterface $connection - * @return void - */ - protected function sendExceptionIfOverCapacity(int $connectionsCount, int $capacity, ConnectionInterface $connection) - { - if ($connectionsCount >= $capacity) { - $payload = json_encode((new ConnectionsOverCapacity)->getPayload()); - - tap($connection)->send($payload)->close(); - } - } } diff --git a/src/Server/WebSocketServerFactory.php b/src/ServerFactory.php similarity index 90% rename from src/Server/WebSocketServerFactory.php rename to src/ServerFactory.php index 163495a..f132635 100644 --- a/src/Server/WebSocketServerFactory.php +++ b/src/ServerFactory.php @@ -1,8 +1,9 @@ routes = $routes; diff --git a/src/Statistics/Collectors/MemoryCollector.php b/src/Statistics/Collectors/MemoryCollector.php new file mode 100644 index 0000000..b56db20 --- /dev/null +++ b/src/Statistics/Collectors/MemoryCollector.php @@ -0,0 +1,171 @@ +channelManager = app(ChannelManager::class); + } + + /** + * Handle the incoming websocket message. + * + * @param string|int $appId + * @return void + */ + public function webSocketMessage($appId) + { + $this->findOrMake($appId) + ->webSocketMessage(); + } + + /** + * Handle the incoming API message. + * + * @param string|int $appId + * @return void + */ + public function apiMessage($appId) + { + $this->findOrMake($appId) + ->apiMessage(); + } + + /** + * Handle the new conection. + * + * @param string|int $appId + * @return void + */ + public function connection($appId) + { + $this->findOrMake($appId) + ->connection(); + } + + /** + * Handle disconnections. + * + * @param string|int $appId + * @return void + */ + public function disconnection($appId) + { + $this->findOrMake($appId) + ->disconnection(); + } + + /** + * Save all the stored statistics. + * + * @return void + */ + public function save() + { + $this->getStatistics()->then(function ($statistics) { + foreach ($statistics as $appId => $statistic) { + if (! $statistic->isEnabled()) { + continue; + } + + $this->createRecord($statistic, $appId); + + $this->channelManager + ->getGlobalConnectionsCount($appId) + ->then(function ($connections) use ($statistic) { + $statistic->reset( + is_null($connections) ? 0 : $connections + ); + }); + } + }); + } + + /** + * Flush the stored statistics. + * + * @return void + */ + public function flush() + { + $this->statistics = []; + } + + /** + * Get the saved statistics. + * + * @return PromiseInterface[array] + */ + public function getStatistics(): PromiseInterface + { + return new FulfilledPromise($this->statistics); + } + + /** + * Get the saved statistics for an app. + * + * @param string|int $appId + * @return PromiseInterface[\BeyondCode\LaravelWebSockets\Statistics\Statistic|null] + */ + public function getAppStatistics($appId): PromiseInterface + { + return new FulfilledPromise( + $this->statistics[$appId] ?? null + ); + } + + /** + * Find or create a defined statistic for an app. + * + * @param string|int $appId + * @return \BeyondCode\LaravelWebSockets\Statistics\Statistic + */ + protected function findOrMake($appId): Statistic + { + if (! isset($this->statistics[$appId])) { + $this->statistics[$appId] = new Statistic($appId); + } + + return $this->statistics[$appId]; + } + + /** + * Create a new record using the Statistic Store. + * + * @param \BeyondCode\LaravelWebSockets\Statistics\Statistic $statistic + * @param mixed $appId + * @return void + */ + public function createRecord(Statistic $statistic, $appId) + { + StatisticsStore::store($statistic->toArray()); + } +} diff --git a/src/Statistics/Collectors/RedisCollector.php b/src/Statistics/Collectors/RedisCollector.php new file mode 100644 index 0000000..5c8dff0 --- /dev/null +++ b/src/Statistics/Collectors/RedisCollector.php @@ -0,0 +1,403 @@ +redis = Redis::connection( + config('websockets.replication.modes.redis.connection', 'default') + ); + } + + /** + * Handle the incoming websocket message. + * + * @param string|int $appId + * @return void + */ + public function webSocketMessage($appId) + { + $this->ensureAppIsInSet($appId) + ->hincrby( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'websocket_messages_count', 1 + ); + } + + /** + * Handle the incoming API message. + * + * @param string|int $appId + * @return void + */ + public function apiMessage($appId) + { + $this->ensureAppIsInSet($appId) + ->hincrby( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'api_messages_count', 1 + ); + } + + /** + * Handle the new conection. + * + * @param string|int $appId + * @return void + */ + public function connection($appId) + { + // Increment the current connections count by 1. + $this->ensureAppIsInSet($appId) + ->hincrby( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'current_connections_count', 1 + ) + ->then(function ($currentConnectionsCount) use ($appId) { + // Get the peak connections count from Redis. + $this->channelManager + ->getPublishClient() + ->hget( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'peak_connections_count' + ) + ->then(function ($currentPeakConnectionCount) use ($currentConnectionsCount, $appId) { + // Extract the greatest number between the current peak connection count + // and the current connection number. + $peakConnectionsCount = is_null($currentPeakConnectionCount) + ? $currentConnectionsCount + : max($currentPeakConnectionCount, $currentConnectionsCount); + + // Then set it to the database. + $this->channelManager + ->getPublishClient() + ->hset( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'peak_connections_count', $peakConnectionsCount + ); + }); + }); + } + + /** + * Handle disconnections. + * + * @param string|int $appId + * @return void + */ + public function disconnection($appId) + { + // Decrement the current connections count by 1. + $this->ensureAppIsInSet($appId) + ->hincrby( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'current_connections_count', -1 + ) + ->then(function ($currentConnectionsCount) use ($appId) { + // Get the peak connections count from Redis. + $this->channelManager + ->getPublishClient() + ->hget( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'peak_connections_count' + ) + ->then(function ($currentPeakConnectionCount) use ($currentConnectionsCount, $appId) { + // Extract the greatest number between the current peak connection count + // and the current connection number. + $peakConnectionsCount = is_null($currentPeakConnectionCount) + ? $currentConnectionsCount + : max($currentPeakConnectionCount, $currentConnectionsCount); + + // Then set it to the database. + $this->channelManager + ->getPublishClient() + ->hset( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'peak_connections_count', $peakConnectionsCount + ); + }); + }); + } + + /** + * Save all the stored statistics. + * + * @return void + */ + public function save() + { + $this->lock()->get(function () { + $this->channelManager + ->getPublishClient() + ->smembers(static::$redisSetName) + ->then(function ($members) { + foreach ($members as $appId) { + $this->channelManager + ->getPublishClient() + ->hgetall($this->channelManager->getRedisKey($appId, null, ['stats'])) + ->then(function ($list) use ($appId) { + if (! $list) { + return; + } + + $statistic = $this->listToStatisticInstance( + $appId, $list + ); + + $this->createRecord($statistic, $appId); + + $this->channelManager + ->getGlobalConnectionsCount($appId) + ->then(function ($currentConnectionsCount) use ($appId) { + $currentConnectionsCount === 0 || is_null($currentConnectionsCount) + ? $this->resetAppTraces($appId) + : $this->resetStatistics($appId, $currentConnectionsCount); + }); + }); + } + }); + }); + } + + /** + * Flush the stored statistics. + * + * @return void + */ + public function flush() + { + $this->getStatistics()->then(function ($statistics) { + foreach ($statistics as $appId => $statistic) { + $this->resetAppTraces($appId); + } + }); + } + + /** + * Get the saved statistics. + * + * @return PromiseInterface[array] + */ + public function getStatistics(): PromiseInterface + { + return $this->channelManager + ->getPublishClient() + ->smembers(static::$redisSetName) + ->then(function ($members) use (&$statistics) { + $appsWithStatistics = []; + + foreach ($members as $appId) { + $this->channelManager + ->getPublishClient() + ->hgetall($this->channelManager->getRedisKey($appId, null, ['stats'])) + ->then(function ($list) use ($appId, &$appsWithStatistics) { + $appsWithStatistics[$appId] = $this->listToStatisticInstance( + $appId, $list + ); + }); + } + + return $appsWithStatistics; + }); + } + + /** + * Get the saved statistics for an app. + * + * @param string|int $appId + * @return PromiseInterface[\BeyondCode\LaravelWebSockets\Statistics\Statistic|null] + */ + public function getAppStatistics($appId): PromiseInterface + { + return $this->channelManager + ->getPublishClient() + ->hgetall($this->channelManager->getRedisKey($appId, null, ['stats'])) + ->then(function ($list) use ($appId, &$appStatistics) { + return $this->listToStatisticInstance( + $appId, $list + ); + }); + } + + /** + * Reset the statistics to a specific connection count. + * + * @param string|int $appId + * @param int $currentConnectionCount + * @return void + */ + public function resetStatistics($appId, int $currentConnectionCount) + { + $this->channelManager + ->getPublishClient() + ->hset( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'current_connections_count', $currentConnectionCount + ); + + $this->channelManager + ->getPublishClient() + ->hset( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'peak_connections_count', $currentConnectionCount + ); + + $this->channelManager + ->getPublishClient() + ->hset( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'websocket_messages_count', 0 + ); + + $this->channelManager + ->getPublishClient() + ->hset( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'api_messages_count', 0 + ); + } + + /** + * Remove all app traces from the database if no connections have been set + * in the meanwhile since last save. + * + * @param string|int $appId + * @return void + */ + public function resetAppTraces($appId) + { + $this->channelManager + ->getPublishClient() + ->hdel( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'current_connections_count' + ); + + $this->channelManager + ->getPublishClient() + ->hdel( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'peak_connections_count' + ); + + $this->channelManager + ->getPublishClient() + ->hdel( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'websocket_messages_count' + ); + + $this->channelManager + ->getPublishClient() + ->hdel( + $this->channelManager->getRedisKey($appId, null, ['stats']), + 'api_messages_count' + ); + + $this->channelManager + ->getPublishClient() + ->srem(static::$redisSetName, $appId); + } + + /** + * Ensure the app id is stored in the Redis database. + * + * @param string|int $appId + * @return \Clue\React\Redis\Client + */ + protected function ensureAppIsInSet($appId) + { + $this->channelManager + ->getPublishClient() + ->sadd(static::$redisSetName, $appId); + + return $this->channelManager->getPublishClient(); + } + + /** + * Get a new RedisLock instance to avoid race conditions. + * + * @return \Illuminate\Cache\CacheLock + */ + protected function lock() + { + return new RedisLock($this->redis, static::$redisLockName, 0); + } + + /** + * Transform the Redis' list of key after value + * to key-value pairs. + * + * @param array $list + * @return array + */ + protected function listToKeyValue(array $list) + { + // Redis lists come into a format where the keys are on even indexes + // and the values are on odd indexes. This way, we know which + // ones are keys and which ones are values and their get combined + // later to form the key => value array. + + [$keys, $values] = collect($list)->partition(function ($value, $key) { + return $key % 2 === 0; + }); + + return array_combine($keys->all(), $values->all()); + } + + /** + * Transform a list coming from a Redis list + * to a Statistic instance. + * + * @param string|int $appId + * @param array $list + * @return \BeyondCode\LaravelWebSockets\Statistics\Statistic + */ + protected function listToStatisticInstance($appId, array $list) + { + $list = $this->listToKeyValue($list); + + return (new Statistic($appId)) + ->setCurrentConnectionsCount($list['current_connections_count'] ?? 0) + ->setPeakConnectionsCount($list['peak_connections_count'] ?? 0) + ->setWebSocketMessagesCount($list['websocket_messages_count'] ?? 0) + ->setApiMessagesCount($list['api_messages_count'] ?? 0); + } +} diff --git a/src/Statistics/Drivers/DatabaseDriver.php b/src/Statistics/Drivers/DatabaseDriver.php deleted file mode 100644 index 034e4d4..0000000 --- a/src/Statistics/Drivers/DatabaseDriver.php +++ /dev/null @@ -1,153 +0,0 @@ -record = $record; - } - - /** - * Get the app ID for the stats. - * - * @return mixed - */ - public function getAppId() - { - return $this->record->app_id; - } - - /** - * Get the time value. Should be Y-m-d H:i:s. - * - * @return string - */ - public function getTime(): string - { - return Carbon::parse($this->record->created_at)->toDateTimeString(); - } - - /** - * Get the peak connection count for the time. - * - * @return int - */ - public function getPeakConnectionCount(): int - { - return $this->record->peak_connection_count ?? 0; - } - - /** - * Get the websocket messages count for the time. - * - * @return int - */ - public function getWebsocketMessageCount(): int - { - return $this->record->websocket_message_count ?? 0; - } - - /** - * Get the API message count for the time. - * - * @return int - */ - public function getApiMessageCount(): int - { - return $this->record->api_message_count ?? 0; - } - - /** - * Create a new statistic in the store. - * - * @param array $data - * @return \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver - */ - public static function create(array $data): StatisticsDriver - { - $class = config('websockets.statistics.database.model'); - - return new static($class::create($data)); - } - - /** - * Get the records to show to the dashboard. - * - * @param mixed $appId - * @param \Illuminate\Http\Request|null $request - * @return array - */ - public static function get($appId, ?Request $request): array - { - $class = config('websockets.statistics.database.model'); - - $statistics = $class::whereAppId($appId) - ->latest() - ->limit(120) - ->get() - ->map(function ($statistic) { - return [ - 'timestamp' => (string) $statistic->created_at, - 'peak_connection_count' => $statistic->peak_connection_count, - 'websocket_message_count' => $statistic->websocket_message_count, - 'api_message_count' => $statistic->api_message_count, - ]; - })->reverse(); - - return [ - 'peak_connections' => [ - 'x' => $statistics->pluck('timestamp'), - 'y' => $statistics->pluck('peak_connection_count'), - ], - 'websocket_message_count' => [ - 'x' => $statistics->pluck('timestamp'), - 'y' => $statistics->pluck('websocket_message_count'), - ], - 'api_message_count' => [ - 'x' => $statistics->pluck('timestamp'), - 'y' => $statistics->pluck('api_message_count'), - ], - ]; - } - - /** - * Delete statistics from the store, - * optionally by app id, returning - * the number of deleted records. - * - * @param mixed $appId - * @return int - */ - public static function delete($appId = null): int - { - $cutOffDate = Carbon::now()->subDay( - config('websockets.statistics.delete_statistics_older_than_days') - )->format('Y-m-d H:i:s'); - - $class = config('websockets.statistics.database.model'); - - return $class::where('created_at', '<', $cutOffDate) - ->when($appId, function ($query) use ($appId) { - return $query->whereAppId($appId); - }) - ->delete(); - } -} diff --git a/src/Statistics/Drivers/StatisticsDriver.php b/src/Statistics/Drivers/StatisticsDriver.php deleted file mode 100644 index fd77b2c..0000000 --- a/src/Statistics/Drivers/StatisticsDriver.php +++ /dev/null @@ -1,78 +0,0 @@ -channelManager = $channelManager; - $this->driver = $driver; - } - - /** - * Handle the incoming websocket message. - * - * @param mixed $appId - * @return void - */ - public function webSocketMessage($appId) - { - $this->findOrMakeStatisticForAppId($appId) - ->webSocketMessage(); - } - - /** - * Handle the incoming API message. - * - * @param mixed $appId - * @return void - */ - public function apiMessage($appId) - { - $this->findOrMakeStatisticForAppId($appId) - ->apiMessage(); - } - - /** - * Handle the new conection. - * - * @param mixed $appId - * @return void - */ - public function connection($appId) - { - $this->findOrMakeStatisticForAppId($appId) - ->connection(); - } - - /** - * Handle disconnections. - * - * @param mixed $appId - * @return void - */ - public function disconnection($appId) - { - $this->findOrMakeStatisticForAppId($appId) - ->disconnection(); - } - - /** - * Save all the stored statistics. - * - * @return void - */ - public function save() - { - foreach ($this->statistics as $appId => $statistic) { - if (! $statistic->isEnabled()) { - continue; - } - - $this->createRecord($statistic, $appId); - - $currentConnectionCount = $this->channelManager->getGlobalConnectionsCount($appId); - - $statistic->reset($currentConnectionCount); - } - } - - /** - * Find or create a defined statistic for an app. - * - * @param mixed $appId - * @return Statistic - */ - protected function findOrMakeStatisticForAppId($appId): Statistic - { - if (! isset($this->statistics[$appId])) { - $this->statistics[$appId] = new Statistic($appId); - } - - return $this->statistics[$appId]; - } - - /** - * Get the saved statistics. - * - * @return array - */ - public function getStatistics(): array - { - return $this->statistics; - } - - /** - * Create a new record using the Statistic Driver. - * - * @param Statistic $statistic - * @param mixed $appId - * @return void - */ - public function createRecord(Statistic $statistic, $appId) - { - $this->driver::create($statistic->toArray()); - } -} diff --git a/src/Statistics/Logger/NullStatisticsLogger.php b/src/Statistics/Logger/NullStatisticsLogger.php deleted file mode 100644 index 1120c2e..0000000 --- a/src/Statistics/Logger/NullStatisticsLogger.php +++ /dev/null @@ -1,90 +0,0 @@ -channelManager = $channelManager; - $this->driver = $driver; - } - - /** - * Handle the incoming websocket message. - * - * @param mixed $appId - * @return void - */ - public function webSocketMessage($appId) - { - // - } - - /** - * Handle the incoming API message. - * - * @param mixed $appId - * @return void - */ - public function apiMessage($appId) - { - // - } - - /** - * Handle the new conection. - * - * @param mixed $appId - * @return void - */ - public function connection($appId) - { - // - } - - /** - * Handle disconnections. - * - * @param mixed $appId - * @return void - */ - public function disconnection($appId) - { - // - } - - /** - * Save all the stored statistics. - * - * @return void - */ - public function save() - { - // - } -} diff --git a/src/Statistics/Logger/RedisStatisticsLogger.php b/src/Statistics/Logger/RedisStatisticsLogger.php deleted file mode 100644 index 696188d..0000000 --- a/src/Statistics/Logger/RedisStatisticsLogger.php +++ /dev/null @@ -1,309 +0,0 @@ -channelManager = $channelManager; - $this->driver = $driver; - $this->replicator = app(ReplicationInterface::class); - - $this->redis = Redis::connection( - config('websockets.replication.redis.connection', 'default') - ); - } - - /** - * Handle the incoming websocket message. - * - * @param mixed $appId - * @return void - */ - public function webSocketMessage($appId) - { - $this->ensureAppIsSet($appId) - ->hincrby($this->getHash($appId), 'websocket_message_count', 1); - } - - /** - * Handle the incoming API message. - * - * @param mixed $appId - * @return void - */ - public function apiMessage($appId) - { - $this->ensureAppIsSet($appId) - ->hincrby($this->getHash($appId), 'api_message_count', 1); - } - - /** - * Handle the new conection. - * - * @param mixed $appId - * @return void - */ - public function connection($appId) - { - // Increment the current connections count by 1. - $incremented = $this->ensureAppIsSet($appId) - ->hincrby($this->getHash($appId), 'current_connection_count', 1); - - $incremented->then(function ($currentConnectionCount) use ($appId) { - // Get the peak connections count from Redis. - $peakConnectionCount = $this->replicator - ->getPublishClient() - ->hget($this->getHash($appId), 'peak_connection_count'); - - $peakConnectionCount->then(function ($currentPeakConnectionCount) use ($currentConnectionCount, $appId) { - // Extract the greatest number between the current peak connection count - // and the current connection number. - - $peakConnectionCount = is_null($currentPeakConnectionCount) - ? $currentConnectionCount - : max($currentPeakConnectionCount, $currentConnectionCount); - - // Then set it to the database. - $this->replicator - ->getPublishClient() - ->hset($this->getHash($appId), 'peak_connection_count', $peakConnectionCount); - }); - }); - } - - /** - * Handle disconnections. - * - * @param mixed $appId - * @return void - */ - public function disconnection($appId) - { - // Decrement the current connections count by 1. - $decremented = $this->ensureAppIsSet($appId) - ->hincrby($this->getHash($appId), 'current_connection_count', -1); - - $decremented->then(function ($currentConnectionCount) use ($appId) { - // Get the peak connections count from Redis. - $peakConnectionCount = $this->replicator - ->getPublishClient() - ->hget($this->getHash($appId), 'peak_connection_count'); - - $peakConnectionCount->then(function ($currentPeakConnectionCount) use ($currentConnectionCount, $appId) { - // Extract the greatest number between the current peak connection count - // and the current connection number. - - $peakConnectionCount = is_null($currentPeakConnectionCount) - ? $currentConnectionCount - : max($currentPeakConnectionCount, $currentConnectionCount); - - // Then set it to the database. - $this->replicator - ->getPublishClient() - ->hset($this->getHash($appId), 'peak_connection_count', $peakConnectionCount); - }); - }); - } - - /** - * Save all the stored statistics. - * - * @return void - */ - public function save() - { - $this->lock()->get(function () { - $setMembers = $this->replicator - ->getPublishClient() - ->smembers('laravel-websockets:apps'); - - $setMembers->then(function ($members) { - foreach ($members as $appId) { - $member = $this->replicator - ->getPublishClient() - ->hgetall($this->getHash($appId)); - - $member->then(function ($statistic) use ($appId) { - if (! $statistic) { - return; - } - - // Statistics come into a list where the keys are on even indexes - // and the values are on odd indexes. This way, we know which - // ones are keys and which ones are values and their get combined - // later to form the key => value array - - [$keys, $values] = collect($statistic)->partition(function ($value, $key) { - return $key % 2 === 0; - }); - - $statistic = array_combine($keys->all(), $values->all()); - - $this->createRecord($statistic, $appId); - - $this->channelManager - ->getGlobalConnectionsCount($appId) - ->then(function ($currentConnectionCount) use ($appId) { - $currentConnectionCount === 0 || is_null($currentConnectionCount) - ? $this->resetAppTraces($appId) - : $this->resetStatistics($appId, $currentConnectionCount); - }); - }); - } - }); - }); - } - - /** - * Ensure the app id is stored in the Redis database. - * - * @param mixed $appId - * @return \Illuminate\Redis\RedisManager - */ - protected function ensureAppIsSet($appId) - { - $this->replicator - ->getPublishClient() - ->sadd('laravel-websockets:apps', $appId); - - return $this->replicator->getPublishClient(); - } - - /** - * Reset the statistics to a specific connection count. - * - * @param mixed $appId - * @param int $currentConnectionCount - * @return void - */ - public function resetStatistics($appId, int $currentConnectionCount) - { - $this->replicator - ->getPublishClient() - ->hset($this->getHash($appId), 'current_connection_count', $currentConnectionCount); - - $this->replicator - ->getPublishClient() - ->hset($this->getHash($appId), 'peak_connection_count', $currentConnectionCount); - - $this->replicator - ->getPublishClient() - ->hset($this->getHash($appId), 'websocket_message_count', 0); - - $this->replicator - ->getPublishClient() - ->hset($this->getHash($appId), 'api_message_count', 0); - } - - /** - * Remove all app traces from the database if no connections have been set - * in the meanwhile since last save. - * - * @param mixed $appId - * @return void - */ - public function resetAppTraces($appId) - { - $this->replicator - ->getPublishClient() - ->hdel($this->getHash($appId), 'current_connection_count'); - - $this->replicator - ->getPublishClient() - ->hdel($this->getHash($appId), 'peak_connection_count'); - - $this->replicator - ->getPublishClient() - ->hdel($this->getHash($appId), 'websocket_message_count'); - - $this->replicator - ->getPublishClient() - ->hdel($this->getHash($appId), 'api_message_count'); - - $this->replicator - ->getPublishClient() - ->srem('laravel-websockets:apps', $appId); - } - - /** - * Get the Redis hash name for the app. - * - * @param mixed $appId - * @return string - */ - protected function getHash($appId): string - { - return "laravel-websockets:app:{$appId}"; - } - - /** - * Get a new RedisLock instance to avoid race conditions. - * - * @return \Illuminate\Cache\CacheLock - */ - protected function lock() - { - return new RedisLock($this->redis, 'laravel-websockets:lock', 0); - } - - /** - * Create a new record using the Statistic Driver. - * - * @param array $statistic - * @param mixed $appId - * @return void - */ - protected function createRecord(array $statistic, $appId): void - { - $this->driver::create([ - 'app_id' => $appId, - 'peak_connection_count' => $statistic['peak_connection_count'] ?? 0, - 'websocket_message_count' => $statistic['websocket_message_count'] ?? 0, - 'api_message_count' => $statistic['api_message_count'] ?? 0, - ]); - } -} diff --git a/src/Statistics/Logger/StatisticsLogger.php b/src/Statistics/Logger/StatisticsLogger.php deleted file mode 100644 index 6f6fe0c..0000000 --- a/src/Statistics/Logger/StatisticsLogger.php +++ /dev/null @@ -1,45 +0,0 @@ -appId = $appId; } + /** + * Set the current connections count. + * + * @param int $currentConnectionsCount + * @return $this + */ + public function setCurrentConnectionsCount(int $currentConnectionsCount) + { + $this->currentConnectionsCount = $currentConnectionsCount; + + return $this; + } + + /** + * Set the peak connections count. + * + * @param int $peakConnectionsCount + * @return $this + */ + public function setPeakConnectionsCount(int $peakConnectionsCount) + { + $this->peakConnectionsCount = $peakConnectionsCount; + + return $this; + } + + /** + * Set the peak connections count. + * + * @param int $webSocketMessagesCount + * @return $this + */ + public function setWebSocketMessagesCount(int $webSocketMessagesCount) + { + $this->webSocketMessagesCount = $webSocketMessagesCount; + + return $this; + } + + /** + * Set the peak connections count. + * + * @param int $apiMessagesCount + * @return $this + */ + public function setApiMessagesCount(int $apiMessagesCount) + { + $this->apiMessagesCount = $apiMessagesCount; + + return $this; + } + /** * Check if the app has statistics enabled. * @@ -69,9 +121,9 @@ class Statistic */ public function connection() { - $this->currentConnectionCount++; + $this->currentConnectionsCount++; - $this->peakConnectionCount = max($this->currentConnectionCount, $this->peakConnectionCount); + $this->peakConnectionsCount = max($this->currentConnectionsCount, $this->peakConnectionsCount); } /** @@ -81,9 +133,9 @@ class Statistic */ public function disconnection() { - $this->currentConnectionCount--; + $this->currentConnectionsCount--; - $this->peakConnectionCount = max($this->currentConnectionCount, $this->peakConnectionCount); + $this->peakConnectionsCount = max($this->currentConnectionsCount, $this->peakConnectionsCount); } /** @@ -93,7 +145,7 @@ class Statistic */ public function webSocketMessage() { - $this->webSocketMessageCount++; + $this->webSocketMessagesCount++; } /** @@ -103,21 +155,21 @@ class Statistic */ public function apiMessage() { - $this->apiMessageCount++; + $this->apiMessagesCount++; } /** * Reset all the connections to a specific count. * - * @param int $currentConnectionCount + * @param int $currentConnectionsCount * @return void */ - public function reset(int $currentConnectionCount) + public function reset(int $currentConnectionsCount) { - $this->currentConnectionCount = $currentConnectionCount; - $this->peakConnectionCount = $currentConnectionCount; - $this->webSocketMessageCount = 0; - $this->apiMessageCount = 0; + $this->currentConnectionsCount = $currentConnectionsCount; + $this->peakConnectionsCount = $currentConnectionsCount; + $this->webSocketMessagesCount = 0; + $this->apiMessagesCount = 0; } /** @@ -129,9 +181,9 @@ class Statistic { return [ 'app_id' => $this->appId, - 'peak_connection_count' => $this->peakConnectionCount, - 'websocket_message_count' => $this->webSocketMessageCount, - 'api_message_count' => $this->apiMessageCount, + 'peak_connections_count' => $this->peakConnectionsCount, + 'websocket_messages_count' => $this->webSocketMessagesCount, + 'api_messages_count' => $this->apiMessagesCount, ]; } } diff --git a/src/Statistics/Stores/DatabaseStore.php b/src/Statistics/Stores/DatabaseStore.php new file mode 100644 index 0000000..d9a6ad4 --- /dev/null +++ b/src/Statistics/Stores/DatabaseStore.php @@ -0,0 +1,116 @@ +toDateTimeString()) + ->when(! is_null($appId), function ($query) use ($appId) { + return $query->whereAppId($appId); + }) + ->delete(); + } + + /** + * Get the query result as eloquent collection. + * + * @param callable $processQuery + * @return \Illuminate\Support\Collection + */ + public function getRawRecords(callable $processQuery = null) + { + return static::$model::query() + ->when(! is_null($processQuery), function ($query) use ($processQuery) { + return call_user_func($processQuery, $query); + }, function ($query) { + return $query->latest()->limit(120); + })->get(); + } + + /** + * Get the results for a specific query. + * + * @param callable $processQuery + * @param callable $processCollection + * @return array + */ + public function getRecords(callable $processQuery = null, callable $processCollection = null): array + { + return $this->getRawRecords($processQuery) + ->when(! is_null($processCollection), function ($collection) use ($processCollection) { + return call_user_func($processCollection, $collection); + }) + ->map(function (Model $statistic) { + return [ + 'timestamp' => (string) $statistic->created_at, + 'peak_connections_count' => $statistic->peak_connections_count, + 'websocket_messages_count' => $statistic->websocket_messages_count, + 'api_messages_count' => $statistic->api_messages_count, + ]; + }) + ->toArray(); + } + + /** + * Get the results for a specific query into a + * format that is easily to read for graphs. + * + * @param callable $processQuery + * @return array + */ + public function getForGraph(callable $processQuery = null): array + { + $statistics = collect( + $this->getRecords($processQuery) + ); + + return [ + 'peak_connections' => [ + 'x' => $statistics->pluck('timestamp')->toArray(), + 'y' => $statistics->pluck('peak_connections_count')->toArray(), + ], + 'websocket_messages_count' => [ + 'x' => $statistics->pluck('timestamp')->toArray(), + 'y' => $statistics->pluck('websocket_messages_count')->toArray(), + ], + 'api_messages_count' => [ + 'x' => $statistics->pluck('timestamp')->toArray(), + 'y' => $statistics->pluck('api_messages_count')->toArray(), + ], + ]; + } +} diff --git a/src/WebSockets/Channels/Channel.php b/src/WebSockets/Channels/Channel.php deleted file mode 100644 index c282563..0000000 --- a/src/WebSockets/Channels/Channel.php +++ /dev/null @@ -1,254 +0,0 @@ -channelName = $channelName; - $this->replicator = app(ReplicationInterface::class); - } - - /** - * Get the channel name. - * - * @return string - */ - public function getChannelName(): string - { - return $this->channelName; - } - - /** - * Check if the channel has connections. - * - * @return bool - */ - public function hasConnections(): bool - { - return count($this->subscribedConnections) > 0; - } - - /** - * Get all subscribed connections. - * - * @return array - */ - public function getSubscribedConnections(): array - { - return $this->subscribedConnections; - } - - /** - * Check if the signature for the payload is valid. - * - * @param \Ratchet\ConnectionInterface $connection - * @param \stdClass $payload - * @return void - * @throws InvalidSignature - */ - protected function verifySignature(ConnectionInterface $connection, stdClass $payload) - { - $signature = "{$connection->socketId}:{$this->channelName}"; - - if (isset($payload->channel_data)) { - $signature .= ":{$payload->channel_data}"; - } - - if (! hash_equals( - hash_hmac('sha256', $signature, $connection->app->secret), - Str::after($payload->auth, ':')) - ) { - throw new InvalidSignature(); - } - } - - /** - * Subscribe to the channel. - * - * @see https://pusher.com/docs/pusher_protocol#presence-channel-events - * @param \Ratchet\ConnectionInterface $connection - * @param \stdClass $payload - * @return void - */ - public function subscribe(ConnectionInterface $connection, stdClass $payload) - { - $this->saveConnection($connection); - - $connection->send(json_encode([ - 'event' => 'pusher_internal:subscription_succeeded', - 'channel' => $this->channelName, - ])); - - $this->replicator->subscribe($connection->app->id, $this->channelName); - - Subscribed::dispatch($this->channelName, $connection); - } - - /** - * Unsubscribe connection from the channel. - * - * @param \Ratchet\ConnectionInterface $connection - * @return void - */ - public function unsubscribe(ConnectionInterface $connection) - { - unset($this->subscribedConnections[$connection->socketId]); - - $this->replicator->unsubscribe($connection->app->id, $this->channelName); - - if (! $this->hasConnections()) { - DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_VACATED, [ - 'socketId' => $connection->socketId, - 'channel' => $this->channelName, - ]); - } - - Unsubscribed::dispatch($this->channelName, $connection); - } - - /** - * Store the connection to the subscribers list. - * - * @param \Ratchet\ConnectionInterface $connection - * @return void - */ - protected function saveConnection(ConnectionInterface $connection) - { - $hadConnectionsPreviously = $this->hasConnections(); - - $this->subscribedConnections[$connection->socketId] = $connection; - - if (! $hadConnectionsPreviously) { - DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_OCCUPIED, [ - 'channel' => $this->channelName, - ]); - } - - DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_SUBSCRIBED, [ - 'socketId' => $connection->socketId, - 'channel' => $this->channelName, - ]); - } - - /** - * Broadcast a payload to the subscribed connections. - * - * @param \stdClass $payload - * @return void - */ - public function broadcast($payload) - { - foreach ($this->subscribedConnections as $connection) { - $connection->send(json_encode($payload)); - } - - MessagesBroadcasted::dispatch(count($this->subscribedConnections)); - } - - /** - * Broadcast the payload, but exclude the current connection. - * - * @param \Ratchet\ConnectionInterface $connection - * @param \stdClass $payload - * @return void - */ - public function broadcastToOthers(ConnectionInterface $connection, stdClass $payload) - { - $this->broadcastToEveryoneExcept( - $payload, $connection->socketId, $connection->app->id - ); - } - - /** - * Broadcast the payload, but exclude a specific socket id. - * - * @param \stdClass $payload - * @param string|null $socketId - * @param mixed $appId - * @param bool $publish - * @return void - */ - public function broadcastToEveryoneExcept(stdClass $payload, ?string $socketId, $appId, bool $publish = true) - { - // Also broadcast via the other websocket server instances. - // This is set false in the Redis client because we don't want to cause a loop - // in this case. If this came from TriggerEventController, then we still want - // to publish to get the message out to other server instances. - if ($publish) { - $this->replicator->publish($appId, $this->channelName, $payload); - } - - // Performance optimization, if we don't have a socket ID, - // then we avoid running the if condition in the foreach loop below - // by calling broadcast() instead. - if (is_null($socketId)) { - $this->broadcast($payload); - - return; - } - - $connections = collect($this->subscribedConnections) - ->reject(function ($connection) use ($socketId) { - return $connection->socketId === $socketId; - }); - - foreach ($connections as $connection) { - $connection->send(json_encode($payload)); - } - - MessagesBroadcasted::dispatch($connections->count()); - } - - /** - * Convert the channel to array. - * - * @param mixed $appId - * @return array - */ - public function toArray($appId = null) - { - return [ - 'occupied' => count($this->subscribedConnections) > 0, - 'subscription_count' => count($this->subscribedConnections), - ]; - } -} diff --git a/src/WebSockets/Channels/ChannelManager.php b/src/WebSockets/Channels/ChannelManager.php deleted file mode 100644 index 2baedc3..0000000 --- a/src/WebSockets/Channels/ChannelManager.php +++ /dev/null @@ -1,58 +0,0 @@ -channels[$appId][$channelName])) { - $channelClass = $this->determineChannelClass($channelName); - - $this->channels[$appId][$channelName] = new $channelClass($channelName); - } - - return $this->channels[$appId][$channelName]; - } - - /** - * Find a channel by name. - * - * @param mixed $appId - * @param string $channelName - * @return \BeyondCode\LaravelWebSockets\WebSockets\Channels - */ - public function find($appId, string $channelName): ?Channel - { - return $this->channels[$appId][$channelName] ?? null; - } - - /** - * Get all channels. - * - * @param mixed $appId - * @return array - */ - public function getChannels($appId): array - { - return $this->channels[$appId] ?? []; - } - - /** - * Get the connections count on the app. - * - * @param mixed $appId - * @return int|\React\Promise\PromiseInterface - */ - public function getLocalConnectionsCount($appId): int - { - return collect($this->getChannels($appId)) - ->flatMap(function (Channel $channel) { - return collect($channel->getSubscribedConnections())->pluck('socketId'); - }) - ->unique() - ->count(); - } - - /** - * Get the connections count across multiple servers. - * - * @param mixed $appId - * @return int|\React\Promise\PromiseInterface - */ - public function getGlobalConnectionsCount($appId) - { - return $this->getLocalConnectionsCount($appId); - } - - /** - * Remove connection from all channels. - * - * @param \Ratchet\ConnectionInterface $connection - * @return void - */ - public function removeFromAllChannels(ConnectionInterface $connection) - { - if (! isset($connection->app)) { - return; - } - - collect(Arr::get($this->channels, $connection->app->id, [])) - ->each->unsubscribe($connection); - - collect(Arr::get($this->channels, $connection->app->id, [])) - ->reject->hasConnections() - ->each(function (Channel $channel, string $channelName) use ($connection) { - unset($this->channels[$connection->app->id][$channelName]); - }); - - if (count(Arr::get($this->channels, $connection->app->id, [])) === 0) { - unset($this->channels[$connection->app->id]); - } - } - - /** - * Get the channel class by the channel name. - * - * @param string $channelName - * @return string - */ - protected function determineChannelClass(string $channelName): string - { - if (Str::startsWith($channelName, 'private-')) { - return PrivateChannel::class; - } - - if (Str::startsWith($channelName, 'presence-')) { - return PresenceChannel::class; - } - - return Channel::class; - } -} diff --git a/src/WebSockets/Channels/ChannelManagers/RedisChannelManager.php b/src/WebSockets/Channels/ChannelManagers/RedisChannelManager.php deleted file mode 100644 index cda98df..0000000 --- a/src/WebSockets/Channels/ChannelManagers/RedisChannelManager.php +++ /dev/null @@ -1,36 +0,0 @@ -replicator = app(ReplicationInterface::class); - } - - /** - * Get the connections count across multiple servers. - * - * @param mixed $appId - * @return int|\React\Promise\PromiseInterface - */ - public function getGlobalConnectionsCount($appId) - { - return $this->replicator->getGlobalConnectionsCount($appId); - } -} diff --git a/src/WebSockets/Channels/PresenceChannel.php b/src/WebSockets/Channels/PresenceChannel.php deleted file mode 100644 index a3e58aa..0000000 --- a/src/WebSockets/Channels/PresenceChannel.php +++ /dev/null @@ -1,178 +0,0 @@ -replicator->channelMembers($appId, $this->channelName); - } - - /** - * Subscribe the connection to the channel. - * - * @param ConnectionInterface $connection - * @param stdClass $payload - * @return void - * @throws InvalidSignature - * @see https://pusher.com/docs/pusher_protocol#presence-channel-events - */ - public function subscribe(ConnectionInterface $connection, stdClass $payload) - { - $this->verifySignature($connection, $payload); - - $this->saveConnection($connection); - - $channelData = json_decode($payload->channel_data); - $this->users[$connection->socketId] = $channelData; - - // Add the connection as a member of the channel - $this->replicator->joinChannel( - $connection->app->id, - $this->channelName, - $connection->socketId, - json_encode($channelData) - ); - - // We need to pull the channel data from the replication backend, - // otherwise we won't be sending the full details of the channel - $this->replicator - ->channelMembers($connection->app->id, $this->channelName) - ->then(function ($users) use ($connection) { - $connection->send(json_encode([ - 'event' => 'pusher_internal:subscription_succeeded', - 'channel' => $this->channelName, - 'data' => json_encode($this->getChannelData($users)), - ])); - }); - - $this->broadcastToOthers($connection, (object) [ - 'event' => 'pusher_internal:member_added', - 'channel' => $this->channelName, - 'data' => json_encode($channelData), - ]); - } - - /** - * Unsubscribe the connection from the Presence channel. - * - * @param ConnectionInterface $connection - * @return void - */ - public function unsubscribe(ConnectionInterface $connection) - { - parent::unsubscribe($connection); - - if (! isset($this->users[$connection->socketId])) { - return; - } - - // Remove the connection as a member of the channel - $this->replicator - ->leaveChannel( - $connection->app->id, - $this->channelName, - $connection->socketId - ); - - $this->broadcastToOthers($connection, (object) [ - 'event' => 'pusher_internal:member_removed', - 'channel' => $this->channelName, - 'data' => json_encode([ - 'user_id' => $this->users[$connection->socketId]->user_id, - ]), - ]); - - unset($this->users[$connection->socketId]); - } - - /** - * Get the Presence Channel to array. - * - * @param string|null $appId - * @return PromiseInterface - */ - public function toArray($appId = null) - { - return $this->replicator - ->channelMembers($appId, $this->channelName) - ->then(function ($users) { - return array_merge(parent::toArray(), [ - 'user_count' => count($users), - ]); - }); - } - - /** - * Get the Presence channel data. - * - * @param array $users - * @return array - */ - protected function getChannelData(array $users): array - { - return [ - 'presence' => [ - 'ids' => $this->getUserIds($users), - 'hash' => $this->getHash($users), - 'count' => count($users), - ], - ]; - } - - /** - * Get the Presence Channel's users. - * - * @param array $users - * @return array - */ - protected function getUserIds(array $users): array - { - $userIds = array_map(function ($channelData) { - return (string) $channelData->user_id; - }, $users); - - return array_values($userIds); - } - - /** - * Compute the hash for the presence channel integrity. - * - * @param array $users - * @return array - */ - protected function getHash(array $users): array - { - $hash = []; - - foreach ($users as $socketId => $channelData) { - $hash[$channelData->user_id] = $channelData->user_info ?? []; - } - - return $hash; - } -} diff --git a/src/WebSockets/Exceptions/InvalidConnection.php b/src/WebSockets/Exceptions/InvalidConnection.php deleted file mode 100644 index 268b55f..0000000 --- a/src/WebSockets/Exceptions/InvalidConnection.php +++ /dev/null @@ -1,18 +0,0 @@ -message = 'Invalid Connection'; - $this->code = 4009; - } -} diff --git a/src/WebSockets/Exceptions/OriginNotAllowed.php b/src/WebSockets/Exceptions/OriginNotAllowed.php deleted file mode 100644 index 87fef2c..0000000 --- a/src/WebSockets/Exceptions/OriginNotAllowed.php +++ /dev/null @@ -1,18 +0,0 @@ -message = "The origin is not allowed for `{$appKey}`."; - $this->code = 4009; - } -} diff --git a/src/WebSockets/Exceptions/UnknownAppKey.php b/src/WebSockets/Exceptions/UnknownAppKey.php deleted file mode 100644 index f872f33..0000000 --- a/src/WebSockets/Exceptions/UnknownAppKey.php +++ /dev/null @@ -1,13 +0,0 @@ -message = "Could not find app key `{$appKey}`."; - - $this->code = 4001; - } -} diff --git a/src/WebSocketsServiceProvider.php b/src/WebSocketsServiceProvider.php index a2ca289..9a46353 100644 --- a/src/WebSocketsServiceProvider.php +++ b/src/WebSocketsServiceProvider.php @@ -2,16 +2,12 @@ namespace BeyondCode\LaravelWebSockets; -use BeyondCode\LaravelWebSockets\Apps\AppManager; use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\AuthenticateDashboard; use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\SendMessage; use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\ShowDashboard; use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\ShowStatistics; use BeyondCode\LaravelWebSockets\Dashboard\Http\Middleware\Authorize as AuthorizeDashboard; use BeyondCode\LaravelWebSockets\Server\Router; -use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver; -use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager; -use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\ArrayChannelManager; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; @@ -26,23 +22,20 @@ class WebSocketsServiceProvider extends ServiceProvider public function boot() { $this->publishes([ - __DIR__.'/../config/websockets.php' => base_path('config/websockets.php'), + __DIR__.'/../config/websockets.php' => config_path('websockets.php'), ], 'config'); + $this->mergeConfigFrom( + __DIR__.'/../config/websockets.php', 'websockets' + ); + $this->publishes([ __DIR__.'/../database/migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php' => database_path('migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php'), ], 'migrations'); - $this->registerDashboardRoutes() - ->registerDashboardGate(); + $this->registerDashboard(); - $this->loadViewsFrom(__DIR__.'/../resources/views/', 'websockets'); - - $this->commands([ - Console\StartWebSocketServer::class, - Console\CleanStatistics::class, - Console\RestartWebSocketServer::class, - ]); + $this->registerCommands(); } /** @@ -52,34 +45,59 @@ class WebSocketsServiceProvider extends ServiceProvider */ public function register() { - $this->mergeConfigFrom(__DIR__.'/../config/websockets.php', 'websockets'); + $this->registerRouter(); + $this->registerManagers(); + } + /** + * Regsiter the dashboard components. + * + * @return void + */ + protected function registerDashboard() + { + $this->loadViewsFrom(__DIR__.'/../resources/views/', 'websockets'); + + $this->registerDashboardRoutes(); + $this->registerDashboardGate(); + } + + /** + * Register the package commands. + * + * @return void + */ + protected function registerCommands() + { + $this->commands([ + Console\Commands\StartServer::class, + Console\Commands\RestartServer::class, + Console\Commands\CleanStatistics::class, + ]); + } + + /** + * Register the routing. + * + * @return void + */ + protected function registerRouter() + { $this->app->singleton('websockets.router', function () { - return new Router(); + return new Router; }); + } - $this->app->singleton(ChannelManager::class, function () { - $replicationDriver = config('websockets.replication.driver', 'local'); - - $class = config("websockets.replication.{$replicationDriver}.channel_manager", ArrayChannelManager::class); - - return new $class; - }); - - $this->app->singleton(AppManager::class, function () { + /** + * Register the managers for the app. + * + * @return void + */ + protected function registerManagers() + { + $this->app->singleton(Contracts\AppManager::class, function () { return $this->app->make(config('websockets.managers.app')); }); - - $this->app->singleton(StatisticsDriver::class, function () { - $driver = config('websockets.statistics.driver', 'local'); - - return $this->app->make( - config( - "websockets.statistics.{$driver}.driver", - \BeyondCode\LaravelWebSockets\Statistics\Drivers\DatabaseDriver::class - ) - ); - }); } /** @@ -99,8 +117,6 @@ class WebSocketsServiceProvider extends ServiceProvider Route::post('/auth', AuthenticateDashboard::class)->name('auth'); Route::post('/event', SendMessage::class)->name('event'); }); - - return $this; } /** @@ -113,7 +129,5 @@ class WebSocketsServiceProvider extends ServiceProvider Gate::define('viewWebSocketsDashboard', function ($user = null) { return $this->app->environment('local'); }); - - return $this; } } diff --git a/tests/Channels/ChannelReplicationTest.php b/tests/Channels/ChannelReplicationTest.php deleted file mode 100644 index adf1e9a..0000000 --- a/tests/Channels/ChannelReplicationTest.php +++ /dev/null @@ -1,158 +0,0 @@ -runOnlyOnRedisReplication(); - } - - /** @test */ - public function replication_clients_can_subscribe_to_channels() - { - $connection = $this->getWebSocketConnection(); - - $message = new Message([ - 'event' => 'pusher:subscribe', - 'data' => [ - 'channel' => 'basic-channel', - ], - ]); - - $this->pusherServer->onOpen($connection); - - $this->pusherServer->onMessage($connection, $message); - - $connection->assertSentEvent('pusher_internal:subscription_succeeded', [ - 'channel' => 'basic-channel', - ]); - } - - /** @test */ - public function replication_clients_can_unsubscribe_from_channels() - { - $connection = $this->getConnectedWebSocketConnection(['test-channel']); - - $channel = $this->getChannel($connection, 'test-channel'); - - $this->assertTrue($channel->hasConnections()); - - $message = new Message([ - 'event' => 'pusher:unsubscribe', - 'data' => [ - 'channel' => 'test-channel', - ], - ]); - - $this->pusherServer->onMessage($connection, $message); - - $this->assertFalse($channel->hasConnections()); - } - - /** @test */ - public function replication_a_client_cannot_broadcast_to_other_clients_by_default() - { - // One connection inside channel "test-channel". - $existingConnection = $this->getConnectedWebSocketConnection(['test-channel']); - - $connection = $this->getConnectedWebSocketConnection(['test-channel']); - - $message = new Message(['event' => 'client-test', 'data' => [], 'channel' => 'test-channel']); - - $this->pusherServer->onMessage($connection, $message); - - $existingConnection->assertNotSentEvent('client-test'); - } - - /** @test */ - public function replication_a_client_can_be_enabled_to_broadcast_to_other_clients() - { - config()->set('websockets.apps.0.enable_client_messages', true); - - // One connection inside channel "test-channel". - $existingConnection = $this->getConnectedWebSocketConnection(['test-channel']); - - $connection = $this->getConnectedWebSocketConnection(['test-channel']); - - $message = new Message(['event' => 'client-test', 'data' => [], 'channel' => 'test-channel']); - - $this->pusherServer->onMessage($connection, $message); - - $existingConnection->assertSentEvent('client-test'); - } - - /** @test */ - public function replication_closed_connections_get_removed_from_all_connected_channels() - { - $connection = $this->getConnectedWebSocketConnection(['test-channel-1', 'test-channel-2']); - - $channel1 = $this->getChannel($connection, 'test-channel-1'); - $channel2 = $this->getChannel($connection, 'test-channel-2'); - - $this->assertTrue($channel1->hasConnections()); - $this->assertTrue($channel2->hasConnections()); - - $this->pusherServer->onClose($connection); - - $this->assertFalse($channel1->hasConnections()); - $this->assertFalse($channel2->hasConnections()); - } - - /** @test */ - public function replication_channels_can_broadcast_messages_to_all_connections() - { - $connection1 = $this->getConnectedWebSocketConnection(['test-channel']); - $connection2 = $this->getConnectedWebSocketConnection(['test-channel']); - - $channel = $this->getChannel($connection1, 'test-channel'); - - $channel->broadcast([ - 'event' => 'broadcasted-event', - 'channel' => 'test-channel', - ]); - - $connection1->assertSentEvent('broadcasted-event'); - $connection2->assertSentEvent('broadcasted-event'); - } - - /** @test */ - public function replication_channels_can_broadcast_messages_to_all_connections_except_the_given_connection() - { - $connection1 = $this->getConnectedWebSocketConnection(['test-channel']); - $connection2 = $this->getConnectedWebSocketConnection(['test-channel']); - - $channel = $this->getChannel($connection1, 'test-channel'); - - $channel->broadcastToOthers($connection1, (object) [ - 'event' => 'broadcasted-event', - 'channel' => 'test-channel', - ]); - - $connection1->assertNotSentEvent('broadcasted-event'); - $connection2->assertSentEvent('broadcasted-event'); - } - - /** @test */ - public function replication_it_responds_correctly_to_the_ping_message() - { - $connection = $this->getConnectedWebSocketConnection(); - - $message = new Message([ - 'event' => 'pusher:ping', - ]); - - $this->pusherServer->onMessage($connection, $message); - - $connection->assertSentEvent('pusher:pong'); - } -} diff --git a/tests/Channels/ChannelTest.php b/tests/Channels/ChannelTest.php deleted file mode 100644 index 333a38d..0000000 --- a/tests/Channels/ChannelTest.php +++ /dev/null @@ -1,148 +0,0 @@ -getWebSocketConnection(); - - $message = new Message([ - 'event' => 'pusher:subscribe', - 'data' => [ - 'channel' => 'basic-channel', - ], - ]); - - $this->pusherServer->onOpen($connection); - - $this->pusherServer->onMessage($connection, $message); - - $connection->assertSentEvent('pusher_internal:subscription_succeeded', [ - 'channel' => 'basic-channel', - ]); - } - - /** @test */ - public function clients_can_unsubscribe_from_channels() - { - $connection = $this->getConnectedWebSocketConnection(['test-channel']); - - $channel = $this->getChannel($connection, 'test-channel'); - - $this->assertTrue($channel->hasConnections()); - - $message = new Message([ - 'event' => 'pusher:unsubscribe', - 'data' => [ - 'channel' => 'test-channel', - ], - ]); - - $this->pusherServer->onMessage($connection, $message); - - $this->assertFalse($channel->hasConnections()); - } - - /** @test */ - public function a_client_cannot_broadcast_to_other_clients_by_default() - { - // One connection inside channel "test-channel". - $existingConnection = $this->getConnectedWebSocketConnection(['test-channel']); - - $connection = $this->getConnectedWebSocketConnection(['test-channel']); - - $message = new Message(['event' => 'client-test', 'data' => [], 'channel' => 'test-channel']); - - $this->pusherServer->onMessage($connection, $message); - - $existingConnection->assertNotSentEvent('client-test'); - } - - /** @test */ - public function a_client_can_be_enabled_to_broadcast_to_other_clients() - { - config()->set('websockets.apps.0.enable_client_messages', true); - - // One connection inside channel "test-channel". - $existingConnection = $this->getConnectedWebSocketConnection(['test-channel']); - - $connection = $this->getConnectedWebSocketConnection(['test-channel']); - - $message = new Message(['event' => 'client-test', 'data' => [], 'channel' => 'test-channel']); - - $this->pusherServer->onMessage($connection, $message); - - $existingConnection->assertSentEvent('client-test'); - } - - /** @test */ - public function closed_connections_get_removed_from_all_connected_channels() - { - $connection = $this->getConnectedWebSocketConnection(['test-channel-1', 'test-channel-2']); - - $channel1 = $this->getChannel($connection, 'test-channel-1'); - $channel2 = $this->getChannel($connection, 'test-channel-2'); - - $this->assertTrue($channel1->hasConnections()); - $this->assertTrue($channel2->hasConnections()); - - $this->pusherServer->onClose($connection); - - $this->assertFalse($channel1->hasConnections()); - $this->assertFalse($channel2->hasConnections()); - } - - /** @test */ - public function channels_can_broadcast_messages_to_all_connections() - { - $connection1 = $this->getConnectedWebSocketConnection(['test-channel']); - $connection2 = $this->getConnectedWebSocketConnection(['test-channel']); - - $channel = $this->getChannel($connection1, 'test-channel'); - - $channel->broadcast([ - 'event' => 'broadcasted-event', - 'channel' => 'test-channel', - ]); - - $connection1->assertSentEvent('broadcasted-event'); - $connection2->assertSentEvent('broadcasted-event'); - } - - /** @test */ - public function channels_can_broadcast_messages_to_all_connections_except_the_given_connection() - { - $connection1 = $this->getConnectedWebSocketConnection(['test-channel']); - $connection2 = $this->getConnectedWebSocketConnection(['test-channel']); - - $channel = $this->getChannel($connection1, 'test-channel'); - - $channel->broadcastToOthers($connection1, (object) [ - 'event' => 'broadcasted-event', - 'channel' => 'test-channel', - ]); - - $connection1->assertNotSentEvent('broadcasted-event'); - $connection2->assertSentEvent('broadcasted-event'); - } - - /** @test */ - public function it_responds_correctly_to_the_ping_message() - { - $connection = $this->getConnectedWebSocketConnection(); - - $message = new Message([ - 'event' => 'pusher:ping', - ]); - - $this->pusherServer->onMessage($connection, $message); - - $connection->assertSentEvent('pusher:pong'); - } -} diff --git a/tests/Channels/PresenceChannelReplicationTest.php b/tests/Channels/PresenceChannelReplicationTest.php deleted file mode 100644 index 67ade9f..0000000 --- a/tests/Channels/PresenceChannelReplicationTest.php +++ /dev/null @@ -1,140 +0,0 @@ -runOnlyOnRedisReplication(); - } - - /** @test */ - public function clients_with_valid_auth_signatures_can_join_presence_channels() - { - $connection = $this->getWebSocketConnection(); - - $this->pusherServer->onOpen($connection); - - $channelData = [ - 'user_id' => 1, - 'user_info' => [ - 'name' => 'Marcel', - ], - ]; - - $signature = "{$connection->socketId}:presence-channel:".json_encode($channelData); - - $message = new Message([ - 'event' => 'pusher:subscribe', - 'data' => [ - 'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret), - 'channel' => 'presence-channel', - 'channel_data' => json_encode($channelData), - ], - ]); - - $this->pusherServer->onMessage($connection, $message); - - $this->getPublishClient() - ->assertCalledWithArgs('hset', [ - 'laravel_database_1234:presence-channel', - $connection->socketId, - json_encode($channelData), - ]) - ->assertCalledWithArgs('hgetall', ['laravel_database_1234:presence-channel']) - ->assertCalled('publish'); - - $this->assertNotNull( - $this->redis->hget('laravel_database_1234:presence-channel', $connection->socketId) - ); - } - - /** @test */ - public function clients_with_valid_auth_signatures_can_leave_presence_channels() - { - $connection = $this->getWebSocketConnection(); - - $this->pusherServer->onOpen($connection); - - $channelData = [ - 'user_id' => 1, - ]; - - $signature = "{$connection->socketId}:presence-channel:".json_encode($channelData); - - $message = new Message([ - 'event' => 'pusher:subscribe', - 'data' => [ - 'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret), - 'channel' => 'presence-channel', - 'channel_data' => json_encode($channelData), - ], - ]); - - $this->pusherServer->onMessage($connection, $message); - - $this->getSubscribeClient() - ->assertEventDispatched('message'); - - $this->getPublishClient() - ->assertCalled('hset') - ->assertCalledWithArgs('hgetall', ['laravel_database_1234:presence-channel']) - ->assertCalled('publish'); - - $this->getPublishClient() - ->resetAssertions(); - - $message = new Message([ - 'event' => 'pusher:unsubscribe', - 'data' => [ - 'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret), - 'channel' => 'presence-channel', - ], - ]); - - $this->pusherServer->onMessage($connection, $message); - - $this->getPublishClient() - ->assertCalled('hdel') - ->assertCalled('publish'); - } - - /** @test */ - public function clients_with_no_user_info_can_join_presence_channels() - { - $connection = $this->getWebSocketConnection(); - - $this->pusherServer->onOpen($connection); - - $channelData = [ - 'user_id' => 1, - ]; - - $signature = "{$connection->socketId}:presence-channel:".json_encode($channelData); - - $message = new Message([ - 'event' => 'pusher:subscribe', - 'data' => [ - 'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret), - 'channel' => 'presence-channel', - 'channel_data' => json_encode($channelData), - ], - ]); - - $this->pusherServer->onMessage($connection, $message); - - $this->getPublishClient() - ->assertCalled('hset') - ->assertCalledWithArgs('hgetall', ['laravel_database_1234:presence-channel']) - ->assertCalled('publish'); - } -} diff --git a/tests/Channels/PresenceChannelTest.php b/tests/Channels/PresenceChannelTest.php deleted file mode 100644 index f6481af..0000000 --- a/tests/Channels/PresenceChannelTest.php +++ /dev/null @@ -1,165 +0,0 @@ -expectException(InvalidSignature::class); - - $connection = $this->getWebSocketConnection(); - - $message = new Message([ - 'event' => 'pusher:subscribe', - 'data' => [ - 'auth' => 'invalid', - 'channel' => 'presence-channel', - ], - ]); - - $this->pusherServer->onOpen($connection); - - $this->pusherServer->onMessage($connection, $message); - } - - /** @test */ - public function clients_with_valid_auth_signatures_can_join_presence_channels() - { - $this->skipOnRedisReplication(); - - $connection = $this->getWebSocketConnection(); - - $this->pusherServer->onOpen($connection); - - $channelData = [ - 'user_id' => 1, - 'user_info' => [ - 'name' => 'Marcel', - ], - ]; - - $signature = "{$connection->socketId}:presence-channel:".json_encode($channelData); - - $message = new Message([ - 'event' => 'pusher:subscribe', - 'data' => [ - 'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret), - 'channel' => 'presence-channel', - 'channel_data' => json_encode($channelData), - ], - ]); - - $this->pusherServer->onMessage($connection, $message); - - $connection->assertSentEvent('pusher_internal:subscription_succeeded', [ - 'channel' => 'presence-channel', - ]); - } - - /** @test */ - public function clients_with_valid_auth_signatures_can_leave_presence_channels() - { - $this->skipOnRedisReplication(); - - $connection = $this->getWebSocketConnection(); - - $this->pusherServer->onOpen($connection); - - $channelData = [ - 'user_id' => 1, - ]; - - $signature = "{$connection->socketId}:presence-channel:".json_encode($channelData); - - $message = new Message([ - 'event' => 'pusher:subscribe', - 'data' => [ - 'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret), - 'channel' => 'presence-channel', - 'channel_data' => json_encode($channelData), - ], - ]); - - $this->pusherServer->onMessage($connection, $message); - - $connection->assertSentEvent('pusher_internal:subscription_succeeded', [ - 'channel' => 'presence-channel', - ]); - - $message = new Message([ - 'event' => 'pusher:unsubscribe', - 'data' => [ - 'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret), - 'channel' => 'presence-channel', - ], - ]); - - $this->pusherServer->onMessage($connection, $message); - } - - /** @test */ - public function clients_with_no_user_info_can_join_presence_channels() - { - $this->skipOnRedisReplication(); - - $connection = $this->getWebSocketConnection(); - - $this->pusherServer->onOpen($connection); - - $channelData = [ - 'user_id' => 1, - ]; - - $signature = "{$connection->socketId}:presence-channel:".json_encode($channelData); - - $message = new Message([ - 'event' => 'pusher:subscribe', - 'data' => [ - 'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret), - 'channel' => 'presence-channel', - 'channel_data' => json_encode($channelData), - ], - ]); - - $this->pusherServer->onMessage($connection, $message); - - $connection->assertSentEvent('pusher_internal:subscription_succeeded', [ - 'channel' => 'presence-channel', - ]); - } - - /** @test */ - public function clients_with_valid_auth_signatures_cannot_leave_channels_they_are_not_in() - { - $connection = $this->getWebSocketConnection(); - - $this->pusherServer->onOpen($connection); - - $channelData = [ - 'user_id' => 1, - 'user_info' => [ - 'name' => 'Marcel', - ], - ]; - - $signature = "{$connection->socketId}:presence-channel:".json_encode($channelData); - - $message = new Message([ - 'event' => 'pusher:unsubscribe', - 'data' => [ - 'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret), - 'channel' => 'presence-channel', - ], - ]); - - $this->pusherServer->onMessage($connection, $message); - - $this->assertTrue(true); - } -} diff --git a/tests/Channels/PrivateChannelReplicationTest.php b/tests/Channels/PrivateChannelReplicationTest.php deleted file mode 100644 index 3a16412..0000000 --- a/tests/Channels/PrivateChannelReplicationTest.php +++ /dev/null @@ -1,66 +0,0 @@ -runOnlyOnRedisReplication(); - } - - /** @test */ - public function replication_clients_need_valid_auth_signatures_to_join_private_channels() - { - $this->expectException(InvalidSignature::class); - - $connection = $this->getWebSocketConnection(); - - $message = new Message([ - 'event' => 'pusher:subscribe', - 'data' => [ - 'auth' => 'invalid', - 'channel' => 'private-channel', - ], - ]); - - $this->pusherServer->onOpen($connection); - - $this->pusherServer->onMessage($connection, $message); - } - - /** @test */ - public function replication_clients_with_valid_auth_signatures_can_join_private_channels() - { - $connection = $this->getWebSocketConnection(); - - $this->pusherServer->onOpen($connection); - - $signature = "{$connection->socketId}:private-channel"; - - $hashedAppSecret = hash_hmac('sha256', $signature, $connection->app->secret); - - $message = new Message([ - 'event' => 'pusher:subscribe', - 'data' => [ - 'auth' => "{$connection->app->key}:{$hashedAppSecret}", - 'channel' => 'private-channel', - ], - ]); - - $this->pusherServer->onMessage($connection, $message); - - $connection->assertSentEvent('pusher_internal:subscription_succeeded', [ - 'channel' => 'private-channel', - ]); - } -} diff --git a/tests/Channels/PrivateChannelTest.php b/tests/Channels/PrivateChannelTest.php deleted file mode 100644 index 91f48d0..0000000 --- a/tests/Channels/PrivateChannelTest.php +++ /dev/null @@ -1,56 +0,0 @@ -expectException(InvalidSignature::class); - - $connection = $this->getWebSocketConnection(); - - $message = new Message([ - 'event' => 'pusher:subscribe', - 'data' => [ - 'auth' => 'invalid', - 'channel' => 'private-channel', - ], - ]); - - $this->pusherServer->onOpen($connection); - - $this->pusherServer->onMessage($connection, $message); - } - - /** @test */ - public function clients_with_valid_auth_signatures_can_join_private_channels() - { - $connection = $this->getWebSocketConnection(); - - $this->pusherServer->onOpen($connection); - - $signature = "{$connection->socketId}:private-channel"; - - $hashedAppSecret = hash_hmac('sha256', $signature, $connection->app->secret); - - $message = new Message([ - 'event' => 'pusher:subscribe', - 'data' => [ - 'auth' => "{$connection->app->key}:{$hashedAppSecret}", - 'channel' => 'private-channel', - ], - ]); - - $this->pusherServer->onMessage($connection, $message); - - $connection->assertSentEvent('pusher_internal:subscription_succeeded', [ - 'channel' => 'private-channel', - ]); - } -} diff --git a/tests/ClientProviders/AppTest.php b/tests/ClientProviders/AppTest.php deleted file mode 100644 index b20e38f..0000000 --- a/tests/ClientProviders/AppTest.php +++ /dev/null @@ -1,34 +0,0 @@ -assertTrue(true); - } - - /** @test */ - public function it_will_not_accept_an_empty_appKey() - { - $this->expectException(InvalidApp::class); - - new App(1, '', 'appSecret'); - } - - /** @test */ - public function it_will_not_accept_an_empty_appSecret() - { - $this->expectException(InvalidApp::class); - - new App(1, 'appKey', ''); - } -} diff --git a/tests/ClientProviders/ConfigAppManagerTest.php b/tests/ClientProviders/ConfigAppManagerTest.php deleted file mode 100644 index 9ba5561..0000000 --- a/tests/ClientProviders/ConfigAppManagerTest.php +++ /dev/null @@ -1,88 +0,0 @@ -appManager = new ConfigAppManager; - } - - /** @test */ - public function it_can_get_apps_from_the_config_file() - { - $apps = $this->appManager->all(); - - $this->assertCount(2, $apps); - - /** @var $app */ - $app = $apps[0]; - - $this->assertEquals('Test App', $app->name); - $this->assertEquals(1234, $app->id); - $this->assertEquals('TestKey', $app->key); - $this->assertEquals('TestSecret', $app->secret); - $this->assertFalse($app->clientMessagesEnabled); - $this->assertTrue($app->statisticsEnabled); - } - - /** @test */ - public function it_can_find_app_by_id() - { - $app = $this->appManager->findById(0000); - - $this->assertNull($app); - - $app = $this->appManager->findById(1234); - - $this->assertEquals('Test App', $app->name); - $this->assertEquals(1234, $app->id); - $this->assertEquals('TestKey', $app->key); - $this->assertEquals('TestSecret', $app->secret); - $this->assertFalse($app->clientMessagesEnabled); - $this->assertTrue($app->statisticsEnabled); - } - - /** @test */ - public function it_can_find_app_by_key() - { - $app = $this->appManager->findByKey('InvalidKey'); - - $this->assertNull($app); - - $app = $this->appManager->findByKey('TestKey'); - - $this->assertEquals('Test App', $app->name); - $this->assertEquals(1234, $app->id); - $this->assertEquals('TestKey', $app->key); - $this->assertEquals('TestSecret', $app->secret); - $this->assertFalse($app->clientMessagesEnabled); - $this->assertTrue($app->statisticsEnabled); - } - - /** @test */ - public function it_can_find_app_by_secret() - { - $app = $this->appManager->findBySecret('InvalidSecret'); - - $this->assertNull($app); - - $app = $this->appManager->findBySecret('TestSecret'); - - $this->assertEquals('Test App', $app->name); - $this->assertEquals(1234, $app->id); - $this->assertEquals('TestKey', $app->key); - $this->assertEquals('TestSecret', $app->secret); - $this->assertFalse($app->clientMessagesEnabled); - $this->assertTrue($app->statisticsEnabled); - } -} diff --git a/tests/Commands/CleanStatisticsTest.php b/tests/Commands/CleanStatisticsTest.php deleted file mode 100644 index 9e26a6d..0000000 --- a/tests/Commands/CleanStatisticsTest.php +++ /dev/null @@ -1,75 +0,0 @@ -app['config']->set('websockets.statistics.delete_statistics_older_than_days', 31); - } - - /** @test */ - public function it_can_clean_the_statistics() - { - Collection::times(60)->each(function (int $index) { - WebSocketsStatisticsEntry::create([ - 'app_id' => 'app_id', - 'peak_connection_count' => 1, - 'websocket_message_count' => 2, - 'api_message_count' => 3, - 'created_at' => Carbon::now()->subDays($index)->startOfDay(), - ]); - }); - - $this->assertCount(60, WebSocketsStatisticsEntry::all()); - - Artisan::call('websockets:clean'); - - $this->assertCount(31, WebSocketsStatisticsEntry::all()); - - $cutOffDate = Carbon::now()->subDays(31)->format('Y-m-d H:i:s'); - - $this->assertCount(0, WebSocketsStatisticsEntry::where('created_at', '<', $cutOffDate)->get()); - } - - /** @test */ - public function it_can_clean_the_statistics_for_app_id_only() - { - Collection::times(60)->each(function (int $index) { - WebSocketsStatisticsEntry::create([ - 'app_id' => 'app_id', - 'peak_connection_count' => 1, - 'websocket_message_count' => 2, - 'api_message_count' => 3, - 'created_at' => Carbon::now()->subDays($index)->startOfDay(), - ]); - }); - - Collection::times(60)->each(function (int $index) { - WebSocketsStatisticsEntry::create([ - 'app_id' => 'app_id2', - 'peak_connection_count' => 1, - 'websocket_message_count' => 2, - 'api_message_count' => 3, - 'created_at' => Carbon::now()->subDays($index)->startOfDay(), - ]); - }); - - $this->assertCount(120, WebSocketsStatisticsEntry::all()); - - Artisan::call('websockets:clean', ['appId' => 'app_id']); - - $this->assertCount(91, WebSocketsStatisticsEntry::all()); - } -} diff --git a/tests/Commands/RestartServerTest.php b/tests/Commands/RestartServerTest.php new file mode 100644 index 0000000..8ea2802 --- /dev/null +++ b/tests/Commands/RestartServerTest.php @@ -0,0 +1,23 @@ +currentTime(); + + $this->artisan('websockets:restart'); + + $this->assertGreaterThanOrEqual( + $start, Cache::get('beyondcode:websockets:restart', 0) + ); + } +} diff --git a/tests/Commands/RestartWebSocketServerTest.php b/tests/Commands/RestartWebSocketServerTest.php deleted file mode 100644 index e80748a..0000000 --- a/tests/Commands/RestartWebSocketServerTest.php +++ /dev/null @@ -1,23 +0,0 @@ -currentTime(); - - Artisan::call('websockets:restart'); - - $this->assertGreaterThanOrEqual($start, Cache::get('beyondcode:websockets:restart', 0)); - } -} diff --git a/tests/Commands/StartServerTest.php b/tests/Commands/StartServerTest.php new file mode 100644 index 0000000..223331c --- /dev/null +++ b/tests/Commands/StartServerTest.php @@ -0,0 +1,15 @@ +artisan('websockets:serve', ['--test' => true, '--debug' => true]); + + $this->assertTrue(true); + } +} diff --git a/tests/Commands/StartWebSocketServerTest.php b/tests/Commands/StartWebSocketServerTest.php deleted file mode 100644 index 00d0d32..0000000 --- a/tests/Commands/StartWebSocketServerTest.php +++ /dev/null @@ -1,16 +0,0 @@ -artisan('websockets:serve', ['--test' => true, '--debug' => true]); - - $this->assertTrue(true); - } -} diff --git a/tests/Commands/StatisticsCleanTest.php b/tests/Commands/StatisticsCleanTest.php new file mode 100644 index 0000000..ea236b9 --- /dev/null +++ b/tests/Commands/StatisticsCleanTest.php @@ -0,0 +1,45 @@ +newActiveConnection(['public-channel']); + $morty = $this->newActiveConnection(['public-channel'], 'TestKey2'); + + $this->statisticsCollector->save(); + + $this->assertCount(2, $records = $this->statisticsStore->getRecords()); + + foreach ($this->statisticsStore->getRawRecords() as $record) { + $record->update(['created_at' => now()->subDays(10)]); + } + + $this->artisan('websockets:clean', [ + 'appId' => '12345', + '--days' => 1, + ]); + + $this->assertCount(1, $records = $this->statisticsStore->getRecords()); + } + + public function test_clean_statistics_older_than_given_days() + { + $rick = $this->newActiveConnection(['public-channel']); + $morty = $this->newActiveConnection(['public-channel'], 'TestKey2'); + + $this->statisticsCollector->save(); + + $this->assertCount(2, $records = $this->statisticsStore->getRecords()); + + foreach ($this->statisticsStore->getRawRecords() as $record) { + $record->update(['created_at' => now()->subDays(10)]); + } + + $this->artisan('websockets:clean', ['--days' => 1]); + + $this->assertCount(0, $records = $this->statisticsStore->getRecords()); + } +} diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index 60392d4..e4e3701 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -1,127 +1,111 @@ expectException(UnknownAppKey::class); - $this->pusherServer->onOpen($this->getWebSocketConnection('test')); + $this->newActiveConnection(['public-channel'], 'NonWorkingKey'); } - /** @test */ - public function known_app_keys_can_connect() + public function test_unconnected_app_cannot_store_statistics() { - $connection = $this->getWebSocketConnection(); + $this->expectException(UnknownAppKey::class); + + $this->newActiveConnection(['public-channel'], 'NonWorkingKey'); + + $this->assertCount(0, $this->statisticsCollector->getStatistics()); + } + + public function test_origin_validation_should_fail_for_no_origin() + { + $this->expectException(OriginNotAllowed::class); + + $connection = $this->newConnection('TestOrigin'); + + $this->pusherServer->onOpen($connection); + } + + public function test_origin_validation_should_fail_for_wrong_origin() + { + $this->expectException(OriginNotAllowed::class); + + $connection = $this->newConnection('TestOrigin', ['Origin' => 'https://google.ro']); + + $this->pusherServer->onOpen($connection); + } + + public function test_origin_validation_should_pass_for_the_right_origin() + { + $connection = $this->newConnection('TestOrigin', ['Origin' => 'https://test.origin.com']); $this->pusherServer->onOpen($connection); $connection->assertSentEvent('pusher:connection_established'); } - /** @test */ - public function app_can_not_exceed_maximum_capacity() + public function test_close_connection() { - $this->runOnlyOnLocalReplication(); + $connection = $this->newActiveConnection(['public-channel']); - $this->app['config']->set('websockets.apps.0.capacity', 2); + $this->channelManager + ->getGlobalChannels('1234') + ->then(function ($channels) { + $this->assertCount(1, $channels); + }); - $this->getConnectedWebSocketConnection(['test-channel']); - $this->getConnectedWebSocketConnection(['test-channel']); - $this->expectException(ConnectionsOverCapacity::class); - $this->getConnectedWebSocketConnection(['test-channel']); + $this->channelManager + ->getGlobalConnectionsCount('1234') + ->then(function ($total) { + $this->assertEquals(1, $total); + }); + + $this->pusherServer->onClose($connection); + + $this->channelManager + ->getGlobalConnectionsCount('1234') + ->then(function ($total) { + $this->assertEquals(0, $total); + }); + + $this->channelManager + ->getGlobalChannels('1234') + ->then(function ($channels) { + $this->assertCount(0, $channels); + }); } - /** @test */ - public function app_can_not_exceed_maximum_capacity_on_redis_replication() + public function test_websocket_exceptions_are_sent() { - $this->runOnlyOnRedisReplication(); + $connection = $this->newActiveConnection(['public-channel']); - $this->redis->hdel('laravel_database_1234', 'connections'); + $this->pusherServer->onError($connection, new UnknownAppKey('NonWorkingKey')); + $connection->assertSentEvent('pusher:error', [ + 'data' => [ + 'message' => 'Could not find app key `NonWorkingKey`.', + 'code' => 4001, + ], + ]); + } + + public function test_capacity_limit() + { $this->app['config']->set('websockets.apps.0.capacity', 2); - $this->getConnectedWebSocketConnection(['test-channel']); - $this->getConnectedWebSocketConnection(['test-channel']); + $this->newActiveConnection(['test-channel']); + $this->newActiveConnection(['test-channel']); - $this->getPublishClient() - ->assertCalledWithArgsCount(2, 'hincrby', ['laravel_database_1234', 'connections', 1]); - - $failedConnection = $this->getConnectedWebSocketConnection(['test-channel']); + $failedConnection = $this->newActiveConnection(['test-channel']); $failedConnection ->assertSentEvent('pusher:error', ['data' => ['message' => 'Over capacity', 'code' => 4100]]) ->assertClosed(); } - - /** @test */ - public function successful_connections_have_the_app_attached() - { - $connection = $this->getWebSocketConnection(); - - $this->pusherServer->onOpen($connection); - - $this->assertInstanceOf(App::class, $connection->app); - $this->assertSame('1234', $connection->app->id); - $this->assertSame('TestKey', $connection->app->key); - $this->assertSame('TestSecret', $connection->app->secret); - $this->assertSame('Test App', $connection->app->name); - } - - /** @test */ - public function ping_returns_pong() - { - $connection = $this->getWebSocketConnection(); - - $message = new Message(['event' => 'pusher:ping']); - - $this->pusherServer->onOpen($connection); - - $this->pusherServer->onMessage($connection, $message); - - $connection->assertSentEvent('pusher:pong'); - } - - /** @test */ - public function origin_validation_should_fail_for_no_origin() - { - $this->expectException(OriginNotAllowed::class); - - $connection = $this->getWebSocketConnection('TestOrigin'); - - $this->pusherServer->onOpen($connection); - - $connection->assertSentEvent('pusher:connection_established'); - } - - /** @test */ - public function origin_validation_should_fail_for_wrong_origin() - { - $this->expectException(OriginNotAllowed::class); - - $connection = $this->getWebSocketConnection('TestOrigin', ['Origin' => 'https://google.ro']); - - $this->pusherServer->onOpen($connection); - - $connection->assertSentEvent('pusher:connection_established'); - } - - /** @test */ - public function origin_validation_should_pass_for_the_right_origin() - { - $connection = $this->getWebSocketConnection('TestOrigin', ['Origin' => 'https://test.origin.com']); - - $this->pusherServer->onOpen($connection); - - $connection->assertSentEvent('pusher:connection_established'); - } } diff --git a/tests/Dashboard/AuthTest.php b/tests/Dashboard/AuthTest.php index cf73ac5..5522bca 100644 --- a/tests/Dashboard/AuthTest.php +++ b/tests/Dashboard/AuthTest.php @@ -1,17 +1,16 @@ getConnectedWebSocketConnection(['test-channel']); + $connection = $this->newActiveConnection(['test-channel']); $this->pusherServer->onOpen($connection); @@ -26,10 +25,9 @@ class AuthTest extends TestCase ]); } - /** @test */ - public function can_authenticate_dashboard_over_private_channel() + public function test_can_authenticate_dashboard_over_private_channel() { - $connection = $this->getWebSocketConnection(); + $connection = $this->newConnection(); $this->pusherServer->onOpen($connection); @@ -61,17 +59,16 @@ class AuthTest extends TestCase ]); } - /** @test */ - public function can_authenticate_dashboard_over_presence_channel() + public function test_can_authenticate_dashboard_over_presence_channel() { - $connection = $this->getWebSocketConnection(); + $connection = $this->newConnection(); $this->pusherServer->onOpen($connection); $channelData = [ 'user_id' => 1, 'user_info' => [ - 'name' => 'Marcel', + 'name' => 'Rick', ], ]; diff --git a/tests/Dashboard/DashboardTest.php b/tests/Dashboard/DashboardTest.php index 1d6716d..d25d1e0 100644 --- a/tests/Dashboard/DashboardTest.php +++ b/tests/Dashboard/DashboardTest.php @@ -1,21 +1,19 @@ get(route('laravel-websockets.dashboard')) ->assertResponseStatus(403); } - /** @test */ - public function can_see_dashboard() + public function test_can_see_dashboard() { $this->actingAs(factory(User::class)->create()) ->get(route('laravel-websockets.dashboard')) diff --git a/tests/Dashboard/RedisStatisticsTest.php b/tests/Dashboard/RedisStatisticsTest.php deleted file mode 100644 index 52b0148..0000000 --- a/tests/Dashboard/RedisStatisticsTest.php +++ /dev/null @@ -1,73 +0,0 @@ -runOnlyOnRedisReplication(); - } - - /** @test */ - public function can_get_statistics() - { - $connection = $this->getConnectedWebSocketConnection(['channel-1']); - - $logger = new RedisStatisticsLogger( - $this->channelManager, - $this->statisticsDriver - ); - - $logger->webSocketMessage($connection->app->id); - $logger->apiMessage($connection->app->id); - $logger->connection($connection->app->id); - $logger->disconnection($connection->app->id); - - $logger->save(); - - $this->actingAs(factory(User::class)->create()) - ->json('GET', route('laravel-websockets.statistics', ['appId' => '1234'])) - ->assertResponseOk() - ->seeJsonStructure([ - 'peak_connections' => ['x', 'y'], - 'websocket_message_count' => ['x', 'y'], - 'api_message_count' => ['x', 'y'], - ]); - } - - /** @test */ - public function cant_get_statistics_for_invalid_app_id() - { - $connection = $this->getConnectedWebSocketConnection(['channel-1']); - - $logger = new RedisStatisticsLogger( - $this->channelManager, - $this->statisticsDriver - ); - - $logger->webSocketMessage($connection->app->id); - $logger->apiMessage($connection->app->id); - $logger->connection($connection->app->id); - $logger->disconnection($connection->app->id); - - $logger->save(); - - $this->actingAs(factory(User::class)->create()) - ->json('GET', route('laravel-websockets.statistics', ['appId' => 'not_found'])) - ->seeJson([ - 'peak_connections' => ['x' => [], 'y' => []], - 'websocket_message_count' => ['x' => [], 'y' => []], - 'api_message_count' => ['x' => [], 'y' => []], - ]); - } -} diff --git a/tests/Dashboard/SendMessageTest.php b/tests/Dashboard/SendMessageTest.php index c6d5dd9..eb71a6b 100644 --- a/tests/Dashboard/SendMessageTest.php +++ b/tests/Dashboard/SendMessageTest.php @@ -1,69 +1,46 @@ skipOnRedisReplication(); - - // Because the Pusher server is not active, - // we expect it to turn out ok: false. - $this->actingAs(factory(User::class)->create()) ->json('POST', route('laravel-websockets.event'), [ 'appId' => '1234', - 'key' => 'TestKey', - 'secret' => 'TestSecret', 'channel' => 'test-channel', 'event' => 'some-event', 'data' => json_encode(['data' => 'yes']), ]) ->seeJson([ - 'ok' => false, + 'ok' => true, ]); + + if (method_exists($this->channelManager, 'getPublishClient')) { + $this->channelManager + ->getPublishClient() + ->assertCalledWithArgs('publish', [ + $this->channelManager->getRedisKey('1234', 'test-channel'), + json_encode([ + 'channel' => 'test-channel', + 'event' => 'some-event', + 'data' => ['data' => 'yes'], + 'appId' => '1234', + 'serverId' => $this->channelManager->getServerId(), + ]), + ]); + } } - /** @test */ - public function can_send_message_on_redis_replication() + public function test_cant_send_message_for_invalid_app() { - $this->skipOnLocalReplication(); - - // Because the Pusher server is not active, - // we expect it to turn out ok: false. - // However, the driver is set to redis, - // so Redis would take care of this - // and stream the message to all active servers instead. - - $this->actingAs(factory(User::class)->create()) - ->json('POST', route('laravel-websockets.event'), [ - 'appId' => '1234', - 'key' => 'TestKey', - 'secret' => 'TestSecret', - 'channel' => 'test-channel', - 'event' => 'some-event', - 'data' => json_encode(['data' => 'yes']), - ]); - } - - /** @test */ - public function cant_send_message_for_invalid_app() - { - $this->skipOnRedisReplication(); - - // Because the Pusher server is not active, - // we expect it to turn out ok: false. - $this->actingAs(factory(User::class)->create()) ->json('POST', route('laravel-websockets.event'), [ 'appId' => '9999', - 'key' => 'TestKey', - 'secret' => 'TestSecret', 'channel' => 'test-channel', 'event' => 'some-event', 'data' => json_encode(['data' => 'yes']), diff --git a/tests/Dashboard/StatisticsTest.php b/tests/Dashboard/StatisticsTest.php index 9de6354..fe5ab50 100644 --- a/tests/Dashboard/StatisticsTest.php +++ b/tests/Dashboard/StatisticsTest.php @@ -1,73 +1,42 @@ newActiveConnection(['public-channel']); + $morty = $this->newActiveConnection(['public-channel']); - $this->runOnlyOnLocalReplication(); - } + $this->statisticsCollector->save(); - /** @test */ - public function can_get_statistics() - { - $connection = $this->getConnectedWebSocketConnection(['channel-1']); - - $logger = new MemoryStatisticsLogger( - $this->channelManager, - $this->statisticsDriver - ); - - $logger->webSocketMessage($connection->app->id); - $logger->apiMessage($connection->app->id); - $logger->connection($connection->app->id); - $logger->disconnection($connection->app->id); - - $logger->save(); - - $this->actingAs(factory(User::class)->create()) + $response = $this->actingAs(factory(User::class)->create()) ->json('GET', route('laravel-websockets.statistics', ['appId' => '1234'])) ->assertResponseOk() ->seeJsonStructure([ 'peak_connections' => ['x', 'y'], - 'websocket_message_count' => ['x', 'y'], - 'api_message_count' => ['x', 'y'], + 'websocket_messages_count' => ['x', 'y'], + 'api_messages_count' => ['x', 'y'], ]); } - /** @test */ - public function cant_get_statistics_for_invalid_app_id() + public function test_cant_get_statistics_for_invalid_app_id() { - $connection = $this->getConnectedWebSocketConnection(['channel-1']); + $rick = $this->newActiveConnection(['public-channel']); + $morty = $this->newActiveConnection(['public-channel']); - $logger = new MemoryStatisticsLogger( - $this->channelManager, - $this->statisticsDriver - ); - - $logger->webSocketMessage($connection->app->id); - $logger->apiMessage($connection->app->id); - $logger->connection($connection->app->id); - $logger->disconnection($connection->app->id); - - $logger->save(); + $this->statisticsCollector->save(); $this->actingAs(factory(User::class)->create()) ->json('GET', route('laravel-websockets.statistics', ['appId' => 'not_found'])) ->seeJson([ 'peak_connections' => ['x' => [], 'y' => []], - 'websocket_message_count' => ['x' => [], 'y' => []], - 'api_message_count' => ['x' => [], 'y' => []], + 'websocket_messages_count' => ['x' => [], 'y' => []], + 'api_messages_count' => ['x' => [], 'y' => []], ]); } } diff --git a/tests/HttpApi/FetchChannelTest.php b/tests/FetchChannelTest.php similarity index 67% rename from tests/HttpApi/FetchChannelTest.php rename to tests/FetchChannelTest.php index e1ca22d..6b274fb 100644 --- a/tests/HttpApi/FetchChannelTest.php +++ b/tests/FetchChannelTest.php @@ -1,10 +1,8 @@ expectException(HttpException::class); $this->expectExceptionMessage('Invalid auth signature provided.'); - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channel/my-channel'; + $routeParams = [ 'appId' => '1234', 'channelName' => 'my-channel', ]; - $queryString = Pusher::build_auth_query_string('TestKey', 'InvalidSecret', 'GET', $requestPath); + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'InvalidSecret', 'GET', $requestPath + ); $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchChannelController::class); + $controller = app(FetchChannel::class); $controller->onOpen($connection, $request); } - /** @test */ - public function it_returns_the_channel_information() + public function test_it_returns_the_channel_information() { - $this->getConnectedWebSocketConnection(['my-channel']); - $this->getConnectedWebSocketConnection(['my-channel']); + $this->newActiveConnection(['my-channel']); + $this->newActiveConnection(['my-channel']); - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channel/my-channel'; $routeParams = [ @@ -53,7 +52,7 @@ class FetchChannelTest extends TestCase $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchChannelController::class); + $controller = app(FetchChannel::class); $controller->onOpen($connection, $request); @@ -66,17 +65,15 @@ class FetchChannelTest extends TestCase ], json_decode($response->getContent(), true)); } - /** @test */ - public function it_returns_presence_channel_information() + public function test_it_returns_presence_channel_information() { - $this->runOnlyOnLocalReplication(); + $this->newPresenceConnection('presence-channel'); + $this->newPresenceConnection('presence-channel'); - $this->joinPresenceChannel('presence-channel'); - $this->joinPresenceChannel('presence-channel'); - - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channel/my-channel'; + $routeParams = [ 'appId' => '1234', 'channelName' => 'presence-channel', @@ -86,7 +83,7 @@ class FetchChannelTest extends TestCase $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchChannelController::class); + $controller = app(FetchChannel::class); $controller->onOpen($connection, $request); @@ -100,17 +97,17 @@ class FetchChannelTest extends TestCase ], json_decode($response->getContent(), true)); } - /** @test */ - public function it_returns_404_for_invalid_channels() + public function test_it_returns_404_for_invalid_channels() { $this->expectException(HttpException::class); $this->expectExceptionMessage('Unknown channel'); - $this->getConnectedWebSocketConnection(['my-channel']); + $this->newActiveConnection(['my-channel']); - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channel/invalid-channel'; + $routeParams = [ 'appId' => '1234', 'channelName' => 'invalid-channel', @@ -120,7 +117,7 @@ class FetchChannelTest extends TestCase $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchChannelController::class); + $controller = app(FetchChannel::class); $controller->onOpen($connection, $request); diff --git a/tests/HttpApi/FetchChannelsTest.php b/tests/FetchChannelsTest.php similarity index 64% rename from tests/HttpApi/FetchChannelsTest.php rename to tests/FetchChannelsTest.php index 05e7fe5..9b0549c 100644 --- a/tests/HttpApi/FetchChannelsTest.php +++ b/tests/FetchChannelsTest.php @@ -1,10 +1,8 @@ expectException(HttpException::class); $this->expectExceptionMessage('Invalid auth signature provided.'); - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channels'; + $routeParams = [ 'appId' => '1234', ]; - $queryString = Pusher::build_auth_query_string('TestKey', 'InvalidSecret', 'GET', $requestPath); + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'InvalidSecret', 'GET', $requestPath + ); $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchChannelsController::class); + $controller = app(FetchChannels::class); $controller->onOpen($connection, $request); } - /** @test */ - public function it_returns_the_channel_information() + public function test_it_returns_the_channel_information() { - $this->skipOnRedisReplication(); + $this->newPresenceConnection('presence-channel'); - $this->joinPresenceChannel('presence-channel'); - - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channels'; + $routeParams = [ 'appId' => '1234', ]; - $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'TestSecret', 'GET', $requestPath + ); $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchChannelsController::class); + $controller = app(FetchChannels::class); $controller->onOpen($connection, $request); @@ -66,19 +66,17 @@ class FetchChannelsTest extends TestCase ], json_decode($response->getContent(), true)); } - /** @test */ - public function it_returns_the_channel_information_for_prefix() + public function test_it_returns_the_channel_information_for_prefix() { - $this->skipOnRedisReplication(); + $this->newPresenceConnection('presence-global.1'); + $this->newPresenceConnection('presence-global.1'); + $this->newPresenceConnection('presence-global.2'); + $this->newPresenceConnection('presence-notglobal.2'); - $this->joinPresenceChannel('presence-global.1'); - $this->joinPresenceChannel('presence-global.1'); - $this->joinPresenceChannel('presence-global.2'); - $this->joinPresenceChannel('presence-notglobal.2'); - - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channels'; + $routeParams = [ 'appId' => '1234', ]; @@ -89,7 +87,7 @@ class FetchChannelsTest extends TestCase $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchChannelsController::class); + $controller = app(FetchChannels::class); $controller->onOpen($connection, $request); @@ -104,19 +102,17 @@ class FetchChannelsTest extends TestCase ], json_decode($response->getContent(), true)); } - /** @test */ - public function it_returns_the_channel_information_for_prefix_with_user_count() + public function test_it_returns_the_channel_information_for_prefix_with_user_count() { - $this->skipOnRedisReplication(); + $this->newPresenceConnection('presence-global.1'); + $this->newPresenceConnection('presence-global.1'); + $this->newPresenceConnection('presence-global.2'); + $this->newPresenceConnection('presence-notglobal.2'); - $this->joinPresenceChannel('presence-global.1'); - $this->joinPresenceChannel('presence-global.1'); - $this->joinPresenceChannel('presence-global.2'); - $this->joinPresenceChannel('presence-notglobal.2'); - - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channels'; + $routeParams = [ 'appId' => '1234', ]; @@ -128,7 +124,7 @@ class FetchChannelsTest extends TestCase $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchChannelsController::class); + $controller = app(FetchChannels::class); $controller->onOpen($connection, $request); @@ -147,15 +143,15 @@ class FetchChannelsTest extends TestCase ], json_decode($response->getContent(), true)); } - /** @test */ - public function can_not_get_non_presence_channel_user_count() + 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 Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channels'; + $routeParams = [ 'appId' => '1234', ]; @@ -166,7 +162,7 @@ class FetchChannelsTest extends TestCase $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchChannelsController::class); + $controller = app(FetchChannels::class); $controller->onOpen($connection, $request); @@ -174,14 +170,12 @@ class FetchChannelsTest extends TestCase $response = array_pop($connection->sentRawData); } - /** @test */ - public function it_returns_empty_object_for_no_channels_found() + public function test_it_returns_empty_object_for_no_channels_found() { - $this->skipOnRedisReplication(); - - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channels'; + $routeParams = [ 'appId' => '1234', ]; @@ -190,7 +184,7 @@ class FetchChannelsTest extends TestCase $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchChannelsController::class); + $controller = app(FetchChannels::class); $controller->onOpen($connection, $request); diff --git a/tests/HttpApi/FetchUsersTest.php b/tests/FetchUsersTest.php similarity index 57% rename from tests/HttpApi/FetchUsersTest.php rename to tests/FetchUsersTest.php index f68af14..bda1e20 100644 --- a/tests/HttpApi/FetchUsersTest.php +++ b/tests/FetchUsersTest.php @@ -1,99 +1,101 @@ expectException(HttpException::class); $this->expectExceptionMessage('Invalid auth signature provided.'); - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channel/my-channel'; + $routeParams = [ 'appId' => '1234', 'channelName' => 'my-channel', ]; - $queryString = Pusher::build_auth_query_string('TestKey', 'InvalidSecret', 'GET', $requestPath); + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'InvalidSecret', 'GET', $requestPath + ); $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchUsersController::class); + $controller = app(FetchUsers::class); $controller->onOpen($connection, $request); } - /** @test */ - public function it_only_returns_data_for_presence_channels() + public function test_it_only_returns_data_for_presence_channels() { $this->expectException(HttpException::class); $this->expectExceptionMessage('Invalid presence channel'); - $this->getConnectedWebSocketConnection(['my-channel']); + $this->newActiveConnection(['my-channel']); - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channel/my-channel/users'; + $routeParams = [ 'appId' => '1234', 'channelName' => 'my-channel', ]; - $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'TestSecret', 'GET', $requestPath + ); $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchUsersController::class); + $controller = app(FetchUsers::class); $controller->onOpen($connection, $request); } - /** @test */ - public function it_returns_404_for_invalid_channels() + public function test_it_returns_404_for_invalid_channels() { $this->expectException(HttpException::class); - $this->expectExceptionMessage('Unknown channel'); + $this->expectExceptionMessage('Invalid presence channel'); - $this->getConnectedWebSocketConnection(['my-channel']); + $this->newActiveConnection(['my-channel']); - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channel/invalid-channel/users'; + $routeParams = [ 'appId' => '1234', 'channelName' => 'invalid-channel', ]; - $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'TestSecret', 'GET', $requestPath + ); $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchUsersController::class); + $controller = app(FetchUsers::class); $controller->onOpen($connection, $request); } - /** @test */ - public function it_returns_connected_user_information() + public function test_it_returns_connected_user_information() { - $this->skipOnRedisReplication(); + $this->newPresenceConnection('presence-channel'); - $this->joinPresenceChannel('presence-channel'); - - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channel/presence-channel/users'; + $routeParams = [ 'appId' => '1234', 'channelName' => 'presence-channel', @@ -103,7 +105,7 @@ class FetchUsersTest extends TestCase $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchUsersController::class); + $controller = app(FetchUsers::class); $controller->onOpen($connection, $request); @@ -111,11 +113,7 @@ class FetchUsersTest extends TestCase $response = array_pop($connection->sentRawData); $this->assertSame([ - 'users' => [ - [ - 'id' => 1, - ], - ], + 'users' => [['id' => 1]], ], json_decode($response->getContent(), true)); } } diff --git a/tests/HttpApi/FetchChannelReplicationTest.php b/tests/HttpApi/FetchChannelReplicationTest.php deleted file mode 100644 index 3d36f91..0000000 --- a/tests/HttpApi/FetchChannelReplicationTest.php +++ /dev/null @@ -1,153 +0,0 @@ -runOnlyOnRedisReplication(); - } - - /** @test */ - public function replication_invalid_signatures_can_not_access_the_api() - { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Invalid auth signature provided.'); - - $connection = new Connection(); - - $requestPath = '/apps/1234/channel/my-channel'; - $routeParams = [ - 'appId' => '1234', - 'channelName' => 'my-channel', - ]; - - $queryString = Pusher::build_auth_query_string('TestKey', 'InvalidSecret', 'GET', $requestPath); - - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - - $controller = app(FetchChannelController::class); - - $controller->onOpen($connection, $request); - } - - /** @test */ - public function replication_it_returns_the_channel_information() - { - $this->getConnectedWebSocketConnection(['my-channel']); - $this->getConnectedWebSocketConnection(['my-channel']); - - $connection = new Connection(); - - $requestPath = '/apps/1234/channel/my-channel'; - $routeParams = [ - 'appId' => '1234', - 'channelName' => 'my-channel', - ]; - - $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); - - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - - $controller = app(FetchChannelController::class); - - $controller->onOpen($connection, $request); - - /** @var JsonResponse $response */ - $response = array_pop($connection->sentRawData); - - $this->assertSame([ - 'occupied' => true, - 'subscription_count' => 2, - ], json_decode($response->getContent(), true)); - } - - /** @test */ - public function replication_it_returns_presence_channel_information() - { - $this->skipOnRedisReplication(); - - $this->joinPresenceChannel('presence-channel'); - $this->joinPresenceChannel('presence-channel'); - - $connection = new Connection(); - - $requestPath = '/apps/1234/channel/my-channel'; - $routeParams = [ - 'appId' => '1234', - 'channelName' => 'presence-channel', - ]; - - $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); - - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - - $controller = app(FetchChannelController::class); - - $controller->onOpen($connection, $request); - - /** @var JsonResponse $response */ - $response = array_pop($connection->sentRawData); - - $this->getSubscribeClient() - ->assertEventDispatched('message'); - - $this->getPublishClient() - ->assertCalled('hset') - ->assertCalled('hgetall') - ->assertCalled('publish'); - - $this->assertSame([ - 'occupied' => true, - 'subscription_count' => 2, - 'user_count' => 2, - ], json_decode($response->getContent(), true)); - } - - /** @test */ - public function replication_it_returns_404_for_invalid_channels() - { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Unknown channel'); - - $this->getConnectedWebSocketConnection(['my-channel']); - - $connection = new Connection(); - - $requestPath = '/apps/1234/channel/invalid-channel'; - $routeParams = [ - 'appId' => '1234', - 'channelName' => 'invalid-channel', - ]; - - $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); - - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - - $controller = app(FetchChannelController::class); - - $controller->onOpen($connection, $request); - - /** @var JsonResponse $response */ - $response = array_pop($connection->sentRawData); - - $this->assertSame([ - 'occupied' => true, - 'subscription_count' => 2, - ], json_decode($response->getContent(), true)); - } -} diff --git a/tests/HttpApi/FetchChannelsReplicationTest.php b/tests/HttpApi/FetchChannelsReplicationTest.php deleted file mode 100644 index 8c691c3..0000000 --- a/tests/HttpApi/FetchChannelsReplicationTest.php +++ /dev/null @@ -1,180 +0,0 @@ -runOnlyOnRedisReplication(); - } - - /** @test */ - public function replication_it_returns_the_channel_information() - { - $this->joinPresenceChannel('presence-channel'); - - $connection = new Connection(); - - $requestPath = '/apps/1234/channels'; - $routeParams = [ - 'appId' => '1234', - ]; - - $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); - - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - - $controller = app(FetchChannelsController::class); - - $controller->onOpen($connection, $request); - - /** @var JsonResponse $response */ - $response = array_pop($connection->sentRawData); - - $this->getSubscribeClient() - ->assertEventDispatched('message'); - - $this->getPublishClient() - ->assertCalled('hset') - ->assertCalledWithArgs('hgetall', ['laravel_database_1234:presence-channel']) - ->assertCalled('publish') - ->assertCalled('multi') - ->assertCalledWithArgs('hlen', ['laravel_database_1234:presence-channel']) - ->assertCalled('exec'); - } - - /** @test */ - public function replication_it_returns_the_channel_information_for_prefix() - { - $this->joinPresenceChannel('presence-global.1'); - $this->joinPresenceChannel('presence-global.1'); - $this->joinPresenceChannel('presence-global.2'); - $this->joinPresenceChannel('presence-notglobal.2'); - - $connection = new Connection(); - - $requestPath = '/apps/1234/channels'; - $routeParams = [ - 'appId' => '1234', - ]; - - $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath, [ - 'filter_by_prefix' => 'presence-global', - ]); - - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - - $controller = app(FetchChannelsController::class); - - $controller->onOpen($connection, $request); - - /** @var JsonResponse $response */ - $response = array_pop($connection->sentRawData); - - $this->getSubscribeClient() - ->assertEventDispatched('message'); - - $this->getPublishClient() - ->assertCalled('hset') - ->assertCalledWithArgs('hgetall', ['laravel_database_1234:presence-global.1']) - ->assertCalledWithArgs('hgetall', ['laravel_database_1234:presence-global.2']) - ->assertCalledWithArgs('hgetall', ['laravel_database_1234:presence-notglobal.2']) - ->assertCalled('publish') - ->assertCalled('multi') - ->assertCalledWithArgs('hlen', ['laravel_database_1234:presence-global.1']) - ->assertCalledWithArgs('hlen', ['laravel_database_1234:presence-global.2']) - ->assertNotCalledWithArgs('hlen', ['laravel_database_1234:presence-notglobal.2']) - ->assertCalled('exec'); - } - - /** @test */ - public function replication_it_returns_the_channel_information_for_prefix_with_user_count() - { - $this->joinPresenceChannel('presence-global.1'); - $this->joinPresenceChannel('presence-global.1'); - $this->joinPresenceChannel('presence-global.2'); - $this->joinPresenceChannel('presence-notglobal.2'); - - $connection = new Connection(); - - $requestPath = '/apps/1234/channels'; - $routeParams = [ - 'appId' => '1234', - ]; - - $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath, [ - 'filter_by_prefix' => 'presence-global', - 'info' => 'user_count', - ]); - - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - - $controller = app(FetchChannelsController::class); - - $controller->onOpen($connection, $request); - - /** @var JsonResponse $response */ - $response = array_pop($connection->sentRawData); - - $this->getSubscribeClient() - ->assertEventDispatched('message'); - - $this->getPublishClient() - ->assertCalled('hset') - ->assertCalledWithArgs('hgetall', ['laravel_database_1234:presence-global.1']) - ->assertCalledWithArgs('hgetall', ['laravel_database_1234:presence-global.2']) - ->assertCalledWithArgs('hgetall', ['laravel_database_1234:presence-notglobal.2']) - ->assertCalled('publish') - ->assertCalled('multi') - ->assertCalledWithArgs('hlen', ['laravel_database_1234:presence-global.1']) - ->assertCalledWithArgs('hlen', ['laravel_database_1234:presence-global.2']) - ->assertNotCalledWithArgs('hlen', ['laravel_database_1234:presence-notglobal.2']) - ->assertCalled('exec'); - } - - /** @test */ - public function replication_it_returns_empty_object_for_no_channels_found() - { - $connection = new Connection(); - - $requestPath = '/apps/1234/channels'; - $routeParams = [ - 'appId' => '1234', - ]; - - $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); - - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - - $controller = app(FetchChannelsController::class); - - $controller->onOpen($connection, $request); - - /** @var JsonResponse $response */ - $response = array_pop($connection->sentRawData); - - $this->getSubscribeClient() - ->assertEventDispatched('message'); - - $this->getPublishClient() - ->assertNotCalled('hset') - ->assertNotCalled('hgetall') - ->assertNotCalled('publish') - ->assertCalled('multi') - ->assertNotCalled('hlen') - ->assertCalled('exec'); - } -} diff --git a/tests/HttpApi/FetchUsersReplicationTest.php b/tests/HttpApi/FetchUsersReplicationTest.php deleted file mode 100644 index 9fa7a96..0000000 --- a/tests/HttpApi/FetchUsersReplicationTest.php +++ /dev/null @@ -1,131 +0,0 @@ -runOnlyOnRedisReplication(); - } - - /** @test */ - public function invalid_signatures_can_not_access_the_api() - { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Invalid auth signature provided.'); - - $connection = new Connection(); - - $requestPath = '/apps/1234/channel/my-channel'; - $routeParams = [ - 'appId' => '1234', - 'channelName' => 'my-channel', - ]; - - $queryString = Pusher::build_auth_query_string('TestKey', 'InvalidSecret', 'GET', $requestPath); - - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - - $controller = app(FetchUsersController::class); - - $controller->onOpen($connection, $request); - } - - /** @test */ - public function it_only_returns_data_for_presence_channels() - { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Invalid presence channel'); - - $this->getConnectedWebSocketConnection(['my-channel']); - - $connection = new Connection(); - - $requestPath = '/apps/1234/channel/my-channel/users'; - $routeParams = [ - 'appId' => '1234', - 'channelName' => 'my-channel', - ]; - - $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); - - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - - $controller = app(FetchUsersController::class); - - $controller->onOpen($connection, $request); - } - - /** @test */ - public function it_returns_404_for_invalid_channels() - { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Unknown channel'); - - $this->getConnectedWebSocketConnection(['my-channel']); - - $connection = new Connection(); - - $requestPath = '/apps/1234/channel/invalid-channel/users'; - $routeParams = [ - 'appId' => '1234', - 'channelName' => 'invalid-channel', - ]; - - $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); - - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - - $controller = app(FetchUsersController::class); - - $controller->onOpen($connection, $request); - } - - /** @test */ - public function it_returns_connected_user_information() - { - $this->skipOnRedisReplication(); - - $this->joinPresenceChannel('presence-channel'); - - $connection = new Connection(); - - $requestPath = '/apps/1234/channel/presence-channel/users'; - $routeParams = [ - 'appId' => '1234', - 'channelName' => 'presence-channel', - ]; - - $queryString = Pusher::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath); - - $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - - $controller = app(FetchUsersController::class); - - $controller->onOpen($connection, $request); - - /** @var \Illuminate\Http\JsonResponse $response */ - $response = array_pop($connection->sentRawData); - - $this->assertSame([ - 'users' => [ - [ - 'id' => 1, - ], - ], - ], json_decode($response->getContent(), true)); - } -} diff --git a/tests/Messages/PusherClientMessageTest.php b/tests/Messages/PusherClientMessageTest.php deleted file mode 100644 index fed8e98..0000000 --- a/tests/Messages/PusherClientMessageTest.php +++ /dev/null @@ -1,63 +0,0 @@ -getConnectedWebSocketConnection(['test-channel']); - - $message = new Message([ - 'event' => 'client-test', - 'channel' => 'test-channel', - 'data' => [ - 'client-event' => 'test', - ], - ]); - - $this->pusherServer->onMessage($connection, $message); - - $connection->assertNotSentEvent('client-test'); - } - - /** @test */ - public function client_messages_get_broadcasted_when_enabled() - { - $this->app['config']->set('websockets.apps', [ - [ - 'name' => 'Test App', - 'id' => 1234, - 'key' => 'TestKey', - 'secret' => 'TestSecret', - 'enable_client_messages' => true, - 'enable_statistics' => true, - ], - ]); - - $connection1 = $this->getConnectedWebSocketConnection(['test-channel']); - $connection2 = $this->getConnectedWebSocketConnection(['test-channel']); - - $message = new Message([ - 'event' => 'client-test', - 'channel' => 'test-channel', - 'data' => [ - 'client-event' => 'test', - ], - ]); - - $this->pusherServer->onMessage($connection1, $message); - - $connection1->assertNotSentEvent('client-test'); - - $connection2->assertSentEvent('client-test', [ - 'data' => [ - 'client-event' => 'test', - ], - ]); - } -} diff --git a/tests/Mocks/Connection.php b/tests/Mocks/Connection.php index f7fb5b4..8de4a7b 100644 --- a/tests/Mocks/Connection.php +++ b/tests/Mocks/Connection.php @@ -1,6 +1,6 @@ statistics as $appId => $statistic) { - $currentConnectionCount = $this->channelManager->getGlobalConnectionsCount($appId); - - $statistic->reset($currentConnectionCount); - } - } - - /** - * Get app by id. - * - * @param mixed $appId - * @return array - */ - public function getForAppId($appId): array - { - $statistic = $this->findOrMakeStatisticForAppId($appId); - - return $statistic->toArray(); - } -} diff --git a/tests/Mocks/FakeRedisStatisticsLogger.php b/tests/Mocks/FakeRedisStatisticsLogger.php deleted file mode 100644 index 8fae00d..0000000 --- a/tests/Mocks/FakeRedisStatisticsLogger.php +++ /dev/null @@ -1,24 +0,0 @@ - $appId, - 'peak_connection_count' => $this->redis->hget($this->getHash($appId), 'peak_connection_count') ?: 0, - 'websocket_message_count' => $this->redis->hget($this->getHash($appId), 'websocket_message_count') ?: 0, - 'api_message_count' => $this->redis->hget($this->getHash($appId), 'api_message_count') ?: 0, - ]; - } -} diff --git a/tests/Mocks/LazyClient.php b/tests/Mocks/LazyClient.php index 0382a6f..abd07ce 100644 --- a/tests/Mocks/LazyClient.php +++ b/tests/Mocks/LazyClient.php @@ -1,6 +1,6 @@ promise, $this->loop ); - $onFulfilled($result); + $result = call_user_func($onFulfilled, $result); - return $this->promise; + return $result instanceof PromiseInterface + ? $result + : new FulfilledPromise($result); } /** diff --git a/tests/Mocks/RedisFactory.php b/tests/Mocks/RedisFactory.php index da28b08..f897f4b 100644 --- a/tests/Mocks/RedisFactory.php +++ b/tests/Mocks/RedisFactory.php @@ -1,6 +1,6 @@ newActiveConnection(['public-channel']); + + $message = new Mocks\Message(['event' => 'pusher:ping']); + + $this->pusherServer->onMessage($connection, $message); + + $connection->assertSentEvent('pusher:pong'); + } +} diff --git a/tests/PresenceChannelTest.php b/tests/PresenceChannelTest.php new file mode 100644 index 0000000..b7d0b8a --- /dev/null +++ b/tests/PresenceChannelTest.php @@ -0,0 +1,188 @@ +expectException(InvalidSignature::class); + + $connection = $this->newConnection(); + + $message = new Mocks\Message([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'auth' => 'invalid', + 'channel' => 'presence-channel', + ], + ]); + + $this->pusherServer->onOpen($connection); + $this->pusherServer->onMessage($connection, $message); + } + + public function test_connect_to_presence_channel_with_valid_signature() + { + $connection = $this->newConnection(); + + $this->pusherServer->onOpen($connection); + + $user = [ + 'user_id' => 1, + 'user_info' => [ + 'name' => 'Rick', + ], + ]; + + $encodedUser = json_encode($user); + + $signature = "{$connection->socketId}:presence-channel:".$encodedUser; + $hashedAppSecret = hash_hmac('sha256', $signature, $connection->app->secret); + + $message = new Mocks\Message([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'auth' => "{$connection->app->key}:{$hashedAppSecret}", + 'channel' => 'presence-channel', + 'channel_data' => json_encode($user), + ], + ]); + + $this->pusherServer->onMessage($connection, $message); + + $connection->assertSentEvent('pusher_internal:subscription_succeeded', [ + 'channel' => 'presence-channel', + ]); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'presence-channel') + ->then(function ($total) { + $this->assertEquals(1, $total); + }); + } + + public function test_presence_channel_broadcast_member_events() + { + $rick = $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + $morty = $this->newPresenceConnection('presence-channel', ['user_id' => 2]); + + $rick->assertSentEvent('pusher_internal:member_added', [ + 'channel' => 'presence-channel', + 'data' => json_encode(['user_id' => 2]), + ]); + + $this->channelManager + ->getChannelMembers('1234', 'presence-channel') + ->then(function ($members) { + $this->assertCount(2, $members); + }); + + $this->pusherServer->onClose($morty); + + $rick->assertSentEvent('pusher_internal:member_removed', [ + 'channel' => 'presence-channel', + 'data' => json_encode(['user_id' => 2]), + ]); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'presence-channel') + ->then(function ($total) { + $this->assertEquals(1, $total); + }); + + $this->channelManager + ->getChannelMembers('1234', 'presence-channel') + ->then(function ($members) { + $this->assertCount(1, $members); + $this->assertEquals(1, $members[0]->user_id); + }); + } + + public function test_unsubscribe_from_presence_channel() + { + $connection = $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'presence-channel') + ->then(function ($total) { + $this->assertEquals(1, $total); + }); + + $message = new Mocks\Message([ + 'event' => 'pusher:unsubscribe', + 'data' => [ + 'channel' => 'presence-channel', + ], + ]); + + $this->pusherServer->onMessage($connection, $message); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'presence-channel') + ->then(function ($total) { + $this->assertEquals(0, $total); + }); + } + + public function test_can_whisper_to_private_channel() + { + $this->app['config']->set('websockets.apps.0.enable_client_messages', true); + + $rick = $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + $morty = $this->newPresenceConnection('presence-channel', ['user_id' => 2]); + + $message = new Mocks\Message([ + 'event' => 'client-test-whisper', + 'data' => [], + 'channel' => 'presence-channel', + ]); + + $this->pusherServer->onMessage($rick, $message); + + $rick->assertNotSentEvent('client-test-whisper'); + $morty->assertSentEvent('client-test-whisper', ['data' => [], 'channel' => 'presence-channel']); + } + + public function test_cannot_whisper_to_public_channel_if_having_whispering_disabled() + { + $rick = $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + $morty = $this->newPresenceConnection('presence-channel', ['user_id' => 2]); + + $message = new Mocks\Message([ + 'event' => 'client-test-whisper', + 'data' => [], + 'channel' => 'presence-channel', + ]); + + $this->pusherServer->onMessage($rick, $message); + + $rick->assertNotSentEvent('client-test-whisper'); + $morty->assertNotSentEvent('client-test-whisper'); + } + + public function test_statistics_get_collected_for_presenece_channels() + { + $rick = $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + $morty = $this->newPresenceConnection('presence-channel', ['user_id' => 2]); + + $this->statisticsCollector + ->getStatistics() + ->then(function ($statistics) { + $this->assertCount(1, $statistics); + }); + + $this->statisticsCollector + ->getAppStatistics('1234') + ->then(function ($statistic) { + $this->assertEquals([ + 'peak_connections_count' => 2, + 'websocket_messages_count' => 2, + 'api_messages_count' => 0, + 'app_id' => '1234', + ], $statistic->toArray()); + }); + } +} diff --git a/tests/PrivateChannelTest.php b/tests/PrivateChannelTest.php new file mode 100644 index 0000000..bfc4807 --- /dev/null +++ b/tests/PrivateChannelTest.php @@ -0,0 +1,141 @@ +expectException(InvalidSignature::class); + + $connection = $this->newConnection(); + + $message = new Mocks\Message([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'auth' => 'invalid', + 'channel' => 'private-channel', + ], + ]); + + $this->pusherServer->onOpen($connection); + $this->pusherServer->onMessage($connection, $message); + } + + public function test_connect_to_private_channel_with_valid_signature() + { + $connection = $this->newConnection(); + + $this->pusherServer->onOpen($connection); + + $signature = "{$connection->socketId}:private-channel"; + $hashedAppSecret = hash_hmac('sha256', $signature, $connection->app->secret); + + $message = new Mocks\Message([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'auth' => "{$connection->app->key}:{$hashedAppSecret}", + 'channel' => 'private-channel', + ], + ]); + + $this->pusherServer->onMessage($connection, $message); + + $connection->assertSentEvent('pusher_internal:subscription_succeeded', [ + 'channel' => 'private-channel', + ]); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'private-channel') + ->then(function ($total) { + $this->assertEquals(1, $total); + }); + } + + public function test_unsubscribe_from_private_channel() + { + $connection = $this->newPrivateConnection('private-channel'); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'private-channel') + ->then(function ($total) { + $this->assertEquals(1, $total); + }); + + $message = new Mocks\Message([ + 'event' => 'pusher:unsubscribe', + 'data' => [ + 'channel' => 'private-channel', + ], + ]); + + $this->pusherServer->onMessage($connection, $message); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'private-channel') + ->then(function ($total) { + $this->assertEquals(0, $total); + }); + } + + public function test_can_whisper_to_private_channel() + { + $this->app['config']->set('websockets.apps.0.enable_client_messages', true); + + $rick = $this->newPrivateConnection('private-channel'); + $morty = $this->newPrivateConnection('private-channel'); + + $message = new Mocks\Message([ + 'event' => 'client-test-whisper', + 'data' => [], + 'channel' => 'private-channel', + ]); + + $this->pusherServer->onMessage($rick, $message); + + $rick->assertNotSentEvent('client-test-whisper'); + $morty->assertSentEvent('client-test-whisper', ['data' => [], 'channel' => 'private-channel']); + } + + public function test_cannot_whisper_to_public_channel_if_having_whispering_disabled() + { + $rick = $this->newPrivateConnection('private-channel'); + $morty = $this->newPrivateConnection('private-channel'); + + $message = new Mocks\Message([ + 'event' => 'client-test-whisper', + 'data' => [], + 'channel' => 'private-channel', + ]); + + $this->pusherServer->onMessage($rick, $message); + + $rick->assertNotSentEvent('client-test-whisper'); + $morty->assertNotSentEvent('client-test-whisper'); + } + + public function test_statistics_get_collected_for_private_channels() + { + $rick = $this->newPrivateConnection('private-channel'); + $morty = $this->newPrivateConnection('private-channel'); + + $this->statisticsCollector + ->getStatistics() + ->then(function ($statistics) { + $this->assertCount(1, $statistics); + }); + + $this->statisticsCollector + ->getAppStatistics('1234') + ->then(function ($statistic) { + $this->assertEquals([ + 'peak_connections_count' => 2, + 'websocket_messages_count' => 2, + 'api_messages_count' => 0, + 'app_id' => '1234', + ], $statistic->toArray()); + }); + } +} diff --git a/tests/PubSub/RedisDriverTest.php b/tests/PubSub/RedisDriverTest.php deleted file mode 100644 index b018fcc..0000000 --- a/tests/PubSub/RedisDriverTest.php +++ /dev/null @@ -1,122 +0,0 @@ -runOnlyOnRedisReplication(); - - Redis::hdel('laravel_database_1234', 'connections'); - } - - /** @test */ - public function redis_listener_responds_properly_on_payload() - { - $connection = $this->getConnectedWebSocketConnection(['test-channel']); - - $this->pusherServer->onOpen($connection); - - $channelData = [ - 'user_id' => 1, - 'user_info' => [ - 'name' => 'Marcel', - ], - ]; - - $payload = json_encode([ - 'appId' => '1234', - 'event' => 'test', - 'data' => $channelData, - 'socketId' => $connection->socketId, - ]); - - $this->getSubscribeClient()->onMessage('1234:test-channel', $payload); - - $this->getSubscribeClient() - ->assertEventDispatched('message') - ->assertCalledWithArgs('subscribe', ['laravel_database_1234:test-channel']) - ->assertCalledWithArgs('onMessage', [ - '1234:test-channel', $payload, - ]); - } - - /** @test */ - public function redis_listener_responds_properly_on_payload_by_direct_call() - { - $connection = $this->getConnectedWebSocketConnection(['test-channel']); - - $this->pusherServer->onOpen($connection); - - $channelData = [ - 'user_id' => 1, - 'user_info' => [ - 'name' => 'Marcel', - ], - ]; - - $payload = json_encode([ - 'appId' => '1234', - 'event' => 'test', - 'data' => $channelData, - 'socketId' => $connection->socketId, - ]); - - $client = (new RedisClient)->boot( - LoopFactory::create(), RedisFactory::class - ); - - $client->onMessage('1234:test-channel', $payload); - - $client->getSubscribeClient() - ->assertEventDispatched('message'); - } - - /** @test */ - public function redis_tracks_app_connections_count() - { - $connection = $this->getWebSocketConnection(); - - $this->pusherServer->onOpen($connection); - - $this->getSubscribeClient() - ->assertCalledWithArgs('subscribe', ['laravel_database_1234']); - - $this->getPublishClient() - ->assertCalledWithArgs('hincrby', ['laravel_database_1234', 'connections', 1]); - } - - /** @test */ - public function redis_tracks_app_connections_count_on_disconnect() - { - $connection = $this->getWebSocketConnection(); - - $this->pusherServer->onOpen($connection); - - $this->getSubscribeClient() - ->assertCalledWithArgs('subscribe', ['laravel_database_1234']) - ->assertNotCalledWithArgs('unsubscribe', ['laravel_database_1234']); - - $this->getPublishClient() - ->assertCalledWithArgs('hincrby', ['laravel_database_1234', 'connections', 1]); - - $this->pusherServer->onClose($connection); - - $this->getPublishClient() - ->assertCalledWithArgs('hincrby', ['laravel_database_1234', 'connections', -1]); - - $this->assertEquals(0, Redis::hget('laravel_database_1234', 'connections')); - } -} diff --git a/tests/PublicChannelTest.php b/tests/PublicChannelTest.php new file mode 100644 index 0000000..373f2f3 --- /dev/null +++ b/tests/PublicChannelTest.php @@ -0,0 +1,117 @@ +newActiveConnection(['public-channel']); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'public-channel') + ->then(function ($total) { + $this->assertEquals(1, $total); + }); + + $connection->assertSentEvent( + 'pusher:connection_established', + [ + 'data' => json_encode([ + 'socket_id' => $connection->socketId, + 'activity_timeout' => 30, + ]), + ], + ); + + $connection->assertSentEvent( + 'pusher_internal:subscription_succeeded', + ['channel' => 'public-channel'] + ); + } + + public function test_unsubscribe_from_public_channel() + { + $connection = $this->newActiveConnection(['public-channel']); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'public-channel') + ->then(function ($total) { + $this->assertEquals(1, $total); + }); + + $message = new Mocks\Message([ + 'event' => 'pusher:unsubscribe', + 'data' => [ + 'channel' => 'public-channel', + ], + ]); + + $this->pusherServer->onMessage($connection, $message); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'public-channel') + ->then(function ($total) { + $this->assertEquals(0, $total); + }); + } + + public function test_can_whisper_to_public_channel() + { + $this->app['config']->set('websockets.apps.0.enable_client_messages', true); + + $rick = $this->newActiveConnection(['public-channel']); + $morty = $this->newActiveConnection(['public-channel']); + + $message = new Mocks\Message([ + 'event' => 'client-test-whisper', + 'data' => [], + 'channel' => 'public-channel', + ]); + + $this->pusherServer->onMessage($rick, $message); + + $rick->assertNotSentEvent('client-test-whisper'); + $morty->assertSentEvent('client-test-whisper', ['data' => [], 'channel' => 'public-channel']); + } + + public function test_cannot_whisper_to_public_channel_if_having_whispering_disabled() + { + $rick = $this->newActiveConnection(['public-channel']); + $morty = $this->newActiveConnection(['public-channel']); + + $message = new Mocks\Message([ + 'event' => 'client-test-whisper', + 'data' => [], + 'channel' => 'public-channel', + ]); + + $this->pusherServer->onMessage($rick, $message); + + $rick->assertNotSentEvent('client-test-whisper'); + $morty->assertNotSentEvent('client-test-whisper'); + } + + public function test_statistics_get_collected_for_public_channels() + { + $rick = $this->newActiveConnection(['public-channel']); + $morty = $this->newActiveConnection(['public-channel']); + + $this->statisticsCollector + ->getStatistics() + ->then(function ($statistics) { + $this->assertCount(1, $statistics); + }); + + $this->statisticsCollector + ->getAppStatistics('1234') + ->then(function ($statistic) { + $this->assertEquals([ + 'peak_connections_count' => 2, + 'websocket_messages_count' => 2, + 'api_messages_count' => 0, + 'app_id' => '1234', + ], $statistic->toArray()); + }); + } +} diff --git a/tests/ReplicationTest.php b/tests/ReplicationTest.php new file mode 100644 index 0000000..00ee615 --- /dev/null +++ b/tests/ReplicationTest.php @@ -0,0 +1,35 @@ +runOnlyOnRedisReplication(); + + $connection = $this->newActiveConnection(['public-channel']); + + $message = [ + 'appId' => '1234', + 'serverId' => 0, + 'event' => 'some-event', + 'data' => [ + 'channel' => 'public-channel', + 'test' => 'yes', + ], + ]; + + $channel = $this->channelManager->find('1234', 'public-channel'); + + $channel->broadcastToEveryoneExcept( + (object) $message, null, '1234', true + ); + + $connection->assertSentEvent('some-event', [ + 'appId' => '1234', + 'serverId' => $this->channelManager->getServerId(), + 'data' => ['channel' => 'public-channel', 'test' => 'yes'], + ]); + } +} diff --git a/tests/Statistics/Logger/RedisStatisticsLoggerTest.php b/tests/Statistics/Logger/RedisStatisticsLoggerTest.php deleted file mode 100644 index 1b70b7f..0000000 --- a/tests/Statistics/Logger/RedisStatisticsLoggerTest.php +++ /dev/null @@ -1,102 +0,0 @@ -runOnlyOnRedisReplication(); - - StatisticsLogger::resetStatistics('1234', 0); - StatisticsLogger::resetAppTraces('1234'); - - $this->redis->hdel('laravel_database_1234', 'connections'); - - $this->getPublishClient()->resetAssertions(); - } - - /** @test */ - public function it_counts_connections_on_redis_replication() - { - $connections = []; - - $connections[] = $this->getConnectedWebSocketConnection(['channel-1']); - $connections[] = $this->getConnectedWebSocketConnection(['channel-1']); - $connections[] = $this->getConnectedWebSocketConnection(['channel-1']); - - $this->getPublishClient() - ->assertCalledWithArgsCount(6, 'sadd', ['laravel-websockets:apps', '1234']) - ->assertCalledWithArgsCount(3, 'hincrby', ['laravel-websockets:app:1234', 'current_connection_count', 1]) - ->assertCalledWithArgsCount(3, 'hincrby', ['laravel-websockets:app:1234', 'websocket_message_count', 1]); - - $this->pusherServer->onClose(array_pop($connections)); - - StatisticsLogger::save(); - - $this->getPublishClient() - ->assertCalledWithArgs('hincrby', ['laravel-websockets:app:1234', 'current_connection_count', -1]) - ->assertCalledWithArgs('smembers', ['laravel-websockets:apps']); - } - - /** @test */ - public function it_counts_unique_connections_no_channel_subscriptions_on_redis() - { - $connections = []; - - $connections[] = $this->getConnectedWebSocketConnection(['channel-1', 'channel-2']); - $connections[] = $this->getConnectedWebSocketConnection(['channel-1', 'channel-2']); - $connections[] = $this->getConnectedWebSocketConnection(['channel-1']); - - $this->getPublishClient() - ->assertCalledWithArgsCount(3, 'hincrby', ['laravel-websockets:app:1234', 'current_connection_count', 1]) - ->assertCalledWithArgsCount(5, 'hincrby', ['laravel-websockets:app:1234', 'websocket_message_count', 1]); - - $this->pusherServer->onClose(array_pop($connections)); - $this->pusherServer->onClose(array_pop($connections)); - - StatisticsLogger::save(); - - $this->getPublishClient() - ->assertCalledWithArgsCount(2, 'hincrby', ['laravel-websockets:app:1234', 'current_connection_count', -1]) - ->assertCalledWithArgs('smembers', ['laravel-websockets:apps']); - } - - /** @test */ - public function it_counts_connections_with_redis_logger_with_no_data() - { - config(['cache.default' => 'redis']); - - $logger = new RedisStatisticsLogger( - $this->channelManager, - $this->statisticsDriver - ); - - $logger->resetAppTraces('1'); - $logger->resetAppTraces('1234'); - - $connection = $this->getConnectedWebSocketConnection(['channel-1']); - - $logger->apiMessage($connection->app->id); - - $logger->save(); - - $this->assertCount(1, WebSocketsStatisticsEntry::all()); - - $entry = WebSocketsStatisticsEntry::first(); - - $this->assertEquals(1, $entry->peak_connection_count); - $this->assertEquals(1, $entry->websocket_message_count); - $this->assertEquals(1, $entry->api_message_count); - } -} diff --git a/tests/Statistics/Logger/StatisticsLoggerTest.php b/tests/Statistics/Logger/StatisticsLoggerTest.php deleted file mode 100644 index 08a8039..0000000 --- a/tests/Statistics/Logger/StatisticsLoggerTest.php +++ /dev/null @@ -1,105 +0,0 @@ -runOnlyOnLocalReplication(); - } - - /** @test */ - public function it_counts_connections() - { - $connections = []; - - $connections[] = $this->getConnectedWebSocketConnection(['channel-1']); - $connections[] = $this->getConnectedWebSocketConnection(['channel-1']); - $connections[] = $this->getConnectedWebSocketConnection(['channel-1']); - - $this->assertEquals(3, StatisticsLogger::getForAppId(1234)['peak_connection_count']); - - $this->pusherServer->onClose(array_pop($connections)); - - StatisticsLogger::save(); - - $this->assertEquals(2, StatisticsLogger::getForAppId(1234)['peak_connection_count']); - } - - /** @test */ - public function it_counts_unique_connections_no_channel_subscriptions() - { - $connections = []; - - $connections[] = $this->getConnectedWebSocketConnection(['channel-1', 'channel-2']); - $connections[] = $this->getConnectedWebSocketConnection(['channel-1', 'channel-2']); - $connections[] = $this->getConnectedWebSocketConnection(['channel-1']); - - $this->assertEquals(3, StatisticsLogger::getForAppId(1234)['peak_connection_count']); - - $this->pusherServer->onClose(array_pop($connections)); - $this->pusherServer->onClose(array_pop($connections)); - - StatisticsLogger::save(); - - $this->assertEquals(1, StatisticsLogger::getForAppId(1234)['peak_connection_count']); - } - - /** @test */ - public function it_counts_connections_with_memory_logger() - { - $connection = $this->getConnectedWebSocketConnection(['channel-1']); - - $logger = new MemoryStatisticsLogger( - $this->channelManager, - $this->statisticsDriver - ); - - $logger->webSocketMessage($connection->app->id); - $logger->apiMessage($connection->app->id); - $logger->connection($connection->app->id); - $logger->disconnection($connection->app->id); - - $logger->save(); - - $this->assertCount(1, WebSocketsStatisticsEntry::all()); - - $entry = WebSocketsStatisticsEntry::first(); - - $this->assertEquals(1, $entry->peak_connection_count); - $this->assertEquals(1, $entry->websocket_message_count); - $this->assertEquals(1, $entry->api_message_count); - } - - /** @test */ - public function it_counts_connections_with_null_logger() - { - $connection = $this->getConnectedWebSocketConnection(['channel-1']); - - $logger = new NullStatisticsLogger( - $this->channelManager, - $this->statisticsDriver - ); - - $logger->webSocketMessage($connection->app->id); - $logger->apiMessage($connection->app->id); - $logger->connection($connection->app->id); - $logger->disconnection($connection->app->id); - - $logger->save(); - - $this->assertCount(0, WebSocketsStatisticsEntry::all()); - } -} diff --git a/tests/Statistics/Rules/AppIdTest.php b/tests/Statistics/Rules/AppIdTest.php deleted file mode 100644 index 0849d0b..0000000 --- a/tests/Statistics/Rules/AppIdTest.php +++ /dev/null @@ -1,18 +0,0 @@ -assertTrue($rule->passes('app_id', config('websockets.apps.0.id'))); - $this->assertFalse($rule->passes('app_id', 'invalid-app-id')); - } -} diff --git a/tests/StatisticsStoreTest.php b/tests/StatisticsStoreTest.php new file mode 100644 index 0000000..6fe6cc2 --- /dev/null +++ b/tests/StatisticsStoreTest.php @@ -0,0 +1,48 @@ +newActiveConnection(['public-channel']); + $morty = $this->newActiveConnection(['public-channel']); + + $this->statisticsCollector->save(); + + $this->assertCount(1, $records = $this->statisticsStore->getRecords()); + + $this->assertEquals('2', $records[0]['peak_connections_count']); + $this->assertEquals('2', $records[0]['websocket_messages_count']); + $this->assertEquals('0', $records[0]['api_messages_count']); + } + + public function test_store_statistics_on_private_channel() + { + $rick = $this->newPrivateConnection('private-channel'); + $morty = $this->newPrivateConnection('private-channel'); + + $this->statisticsCollector->save(); + + $this->assertCount(1, $records = $this->statisticsStore->getRecords()); + + $this->assertEquals('2', $records[0]['peak_connections_count']); + $this->assertEquals('2', $records[0]['websocket_messages_count']); + $this->assertEquals('0', $records[0]['api_messages_count']); + } + + public function test_store_statistics_on_presence_channel() + { + $rick = $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + $morty = $this->newPresenceConnection('presence-channel', ['user_id' => 2]); + + $this->statisticsCollector->save(); + + $this->assertCount(1, $records = $this->statisticsStore->getRecords()); + + $this->assertEquals('2', $records[0]['peak_connections_count']); + $this->assertEquals('2', $records[0]['websocket_messages_count']); + $this->assertEquals('0', $records[0]['api_messages_count']); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index d83bd9b..e62b10d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,43 +1,51 @@ loop = LoopFactory::create(); + $this->replicationMode = getenv('REPLICATION_MODE') ?: 'local'; + $this->resetDatabase(); - $this->loadLaravelMigrations(['--database' => 'sqlite']); - + $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); $this->withFactories(__DIR__.'/database/factories'); - $this->configurePubSub(); + $this->registerManagers(); - $this->channelManager = $this->app->make(ChannelManager::class); + $this->registerStatisticsCollectors(); - $this->statisticsDriver = $this->app->make(StatisticsDriver::class); - - $this->configureStatisticsLogger(); - - $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + $this->registerStatisticsStores(); $this->pusherServer = $this->app->make(config('websockets.handlers.websocket')); + + if ($this->replicationMode === 'redis') { + $this->registerRedis(); + } } /** @@ -95,20 +104,54 @@ abstract class TestCase extends BaseTestCase /** * {@inheritdoc} */ - protected function getEnvironmentSetUp($app) + public function getEnvironmentSetUp($app) { - $app['config']->set('app.key', 'wslxrEFGWY6GfGhvN9L3wH3KSRJQQpBD'); - - $app['config']->set('auth.providers.users.model', Models\User::class); + $this->replicationMode = getenv('REPLICATION_MODE') ?: 'local'; $app['config']->set('database.default', 'sqlite'); $app['config']->set('database.connections.sqlite', [ - 'driver' => 'sqlite', + 'driver' => 'sqlite', 'database' => __DIR__.'/database.sqlite', - 'prefix' => '', + 'prefix' => '', ]); + $app['config']->set( + 'broadcasting.connections.websockets', [ + 'driver' => 'pusher', + 'key' => 'TestKey', + 'secret' => 'TestSecret', + 'app_id' => '1234', + 'options' => [ + 'cluster' => 'mt1', + 'encrypted' => true, + 'host' => '127.0.0.1', + 'port' => 6001, + 'scheme' => 'http', + ], + ] + ); + + $app['config']->set('auth.providers.users.model', Models\User::class); + + $app['config']->set('app.key', 'wslxrEFGWY6GfGhvN9L3wH3KSRJQQpBD'); + + $app['config']->set('database.redis.default', [ + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'password' => env('REDIS_PASSWORD', null), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + ]); + + $app['config']->set( + 'websockets.replication.mode', $this->replicationMode + ); + + if ($this->replicationMode === 'redis') { + $app['config']->set('broadcasting.default', 'pusher'); + $app['config']->set('cache.default', 'redis'); + } + $app['config']->set('websockets.apps', [ [ 'name' => 'Test App', @@ -133,53 +176,109 @@ abstract class TestCase extends BaseTestCase 'test.origin.com', ], ], + [ + 'name' => 'Test App 2', + 'id' => '12345', + 'key' => 'TestKey2', + 'secret' => 'TestSecret2', + 'host' => 'localhost', + 'capacity' => null, + 'enable_client_messages' => false, + 'enable_statistics' => true, + 'allowed_origins' => [], + ], ]); - $app['config']->set('database.redis.default', [ - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'password' => env('REDIS_PASSWORD', null), - 'port' => env('REDIS_PORT', '6379'), - 'database' => env('REDIS_DB', '0'), + $app['config']->set('websockets.replication.modes', [ + 'local' => [ + 'channel_manager' => \BeyondCode\LaravelWebSockets\ChannelManagers\LocalChannelManager::class, + 'collector' => \BeyondCode\LaravelWebSockets\Statistics\Collectors\MemoryCollector::class, + ], + 'redis' => [ + 'channel_manager' => \BeyondCode\LaravelWebSockets\ChannelManagers\RedisChannelManager::class, + 'connection' => 'default', + 'collector' => \BeyondCode\LaravelWebSockets\Statistics\Collectors\RedisCollector::class, + ], ]); - - $replicationDriver = getenv('REPLICATION_DRIVER') ?: 'local'; - - $app['config']->set( - 'websockets.replication.driver', $replicationDriver - ); - - $app['config']->set( - 'broadcasting.connections.websockets', [ - 'driver' => 'pusher', - 'key' => 'TestKey', - 'secret' => 'TestSecret', - 'app_id' => '1234', - 'options' => [ - 'cluster' => 'mt1', - 'encrypted' => true, - 'host' => '127.0.0.1', - 'port' => 6001, - 'scheme' => 'http', - ], - ] - ); - - if (in_array($replicationDriver, ['redis'])) { - $app['config']->set('broadcasting.default', 'pusher'); - $app['config']->set('cache.default', 'redis'); - } } /** - * Get the websocket connection for a specific URL. + * Register the managers that are not resolved + * by the package service provider. * - * @param mixed $appKey - * @param array $headers - * @return \BeyondCode\LaravelWebSockets\Tests\Mocks\Connection + * @return void */ - protected function getWebSocketConnection(string $appKey = 'TestKey', array $headers = []): Connection + protected function registerManagers() { - $connection = new Connection; + $this->app->singleton(ChannelManager::class, function () { + $mode = config('websockets.replication.mode', $this->replicationMode); + + $class = config("websockets.replication.modes.{$mode}.channel_manager"); + + return new $class($this->loop, Mocks\RedisFactory::class); + }); + + $this->channelManager = $this->app->make(ChannelManager::class); + } + + /** + * Register the statistics collectors that are + * not resolved by the package service provider. + * + * @return void + */ + protected function registerStatisticsCollectors() + { + $this->app->singleton(StatisticsCollector::class, function () { + $class = config("websockets.replication.modes.{$this->replicationMode}.collector"); + + return new $class; + }); + + $this->statisticsCollector = $this->app->make(StatisticsCollector::class); + + $this->statisticsCollector->flush(); + } + + /** + * Register the statistics stores that are + * not resolved by the package service provider. + * + * @return void + */ + protected function registerStatisticsStores() + { + $this->app->singleton(StatisticsStore::class, function () { + $class = config('websockets.statistics.store'); + + return new $class; + }); + + $this->statisticsStore = $this->app->make(StatisticsStore::class); + } + + /** + * Register the Redis components for testing. + * + * @return void + */ + protected function registerRedis() + { + $this->redis = Redis::connection(); + + $this->redis->flushdb(); + } + + /** + * Get the websocket connection for a specific key. + * + * @param string $appKey + * @param array $headers + * @return Mocks\Connection + */ + protected function newConnection(string $appKey = 'TestKey', array $headers = []) + { + $connection = new Mocks\Connection; $connection->httpRequest = new Request('GET', "/?appKey={$appKey}", $headers); @@ -192,18 +291,16 @@ abstract class TestCase extends BaseTestCase * @param array $channelsToJoin * @param string $appKey * @param array $headers - * @return \BeyondCode\LaravelWebSockets\Tests\Mocks\Connection + * @return Mocks\Connection */ - protected function getConnectedWebSocketConnection(array $channelsToJoin = [], string $appKey = 'TestKey', array $headers = []): Connection + protected function newActiveConnection(array $channelsToJoin = [], string $appKey = 'TestKey', array $headers = []) { - $connection = new Connection; - - $connection->httpRequest = new Request('GET', "/?appKey={$appKey}", $headers); + $connection = $this->newConnection($appKey, $headers); $this->pusherServer->onOpen($connection); foreach ($channelsToJoin as $channel) { - $message = new Message([ + $message = new Mocks\Message([ 'event' => 'pusher:subscribe', 'data' => [ 'channel' => $channel, @@ -220,29 +317,30 @@ abstract class TestCase extends BaseTestCase * Join a presence channel. * * @param string $channel - * @return \BeyondCode\LaravelWebSockets\Tests\Mocks\Connection + * @param array $user + * @return Mocks\Connection */ - protected function joinPresenceChannel($channel): Connection + protected function newPresenceConnection($channel, array $user = []) { - $connection = $this->getWebSocketConnection(); + $connection = $this->newConnection(); $this->pusherServer->onOpen($connection); - $channelData = [ + $user = $user ?: [ 'user_id' => 1, - 'user_info' => [ - 'name' => 'Marcel', - ], + 'user_info' => ['name' => 'Rick'], ]; - $signature = "{$connection->socketId}:{$channel}:".json_encode($channelData); + $signature = "{$connection->socketId}:{$channel}:".json_encode($user); - $message = new Message([ + $hash = hash_hmac('sha256', $signature, $connection->app->secret); + + $message = new Mocks\Message([ 'event' => 'pusher:subscribe', 'data' => [ - 'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret), + 'auth' => "{$connection->app->key}:{$hash}", 'channel' => $channel, - 'channel_data' => json_encode($channelData), + 'channel_data' => json_encode($user), ], ]); @@ -252,119 +350,52 @@ abstract class TestCase extends BaseTestCase } /** - * Get a channel from connection. + * Join a private channel. * - * @param \Ratchet\ConnectionInterface $connection - * @param string $channelName - * @return \BeyondCode\LaravelWebSockets\WebSockets\Channels\Channel|null + * @param string $channel + * @return Mocks\Connection */ - protected function getChannel(ConnectionInterface $connection, string $channelName) + protected function newPrivateConnection($channel) { - return $this->channelManager->findOrCreate($connection->app->id, $channelName); - } + $connection = $this->newConnection(); - /** - * Configure the replicator clients. - * - * @return void - */ - protected function configurePubSub() - { - $replicationDriver = config('websockets.replication.driver', 'local'); + $this->pusherServer->onOpen($connection); - // Replace the publish and subscribe clients with a Mocked - // factory lazy instance on boot. - $this->app->singleton(ReplicationInterface::class, function () use ($replicationDriver) { - $client = config( - "websockets.replication.{$replicationDriver}.client", - \BeyondCode\LaravelWebSockets\PubSub\Drivers\LocalClient::class - ); + $signature = "{$connection->socketId}:{$channel}"; - return (new $client)->boot( - $this->loop, Mocks\RedisFactory::class - ); - }); + $hash = hash_hmac('sha256', $signature, $connection->app->secret); - if ($replicationDriver === 'redis') { - $this->redis = Redis::connection(); - } - } + $message = new Mocks\Message([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'auth' => "{$connection->app->key}:{$hash}", + 'channel' => $channel, + ], + ]); - /** - * Configure the statistics logger for the right driver. - * - * @return void - */ - protected function configureStatisticsLogger() - { - $replicationDriver = getenv('REPLICATION_DRIVER') ?: 'local'; + $this->pusherServer->onMessage($connection, $message); - if ($replicationDriver === 'local') { - StatisticsLogger::swap(new FakeMemoryStatisticsLogger( - $this->channelManager, - app(StatisticsDriver::class) - )); - } - - if ($replicationDriver === 'redis') { - StatisticsLogger::swap(new FakeRedisStatisticsLogger( - $this->channelManager, - app(StatisticsDriver::class), - $this->app->make(ReplicationInterface::class) - )); - } - } - - protected function runOnlyOnRedisReplication() - { - if (config('websockets.replication.driver') !== 'redis') { - $this->markTestSkipped('Skipped test because the replication driver is not set to Redis.'); - } - } - - protected function runOnlyOnLocalReplication() - { - if (config('websockets.replication.driver') !== 'local') { - $this->markTestSkipped('Skipped test because the replication driver is not set to Local.'); - } - } - - protected function skipOnRedisReplication() - { - if (config('websockets.replication.driver') === 'redis') { - $this->markTestSkipped('Skipped test because the replication driver is Redis.'); - } - } - - protected function skipOnLocalReplication() - { - if (config('websockets.replication.driver') === 'local') { - $this->markTestSkipped('Skipped test because the replication driver is Local.'); - } + return $connection; } /** * Get the subscribed client for the replication. * - * @return ReplicationInterface + * @return Mocks\LazyClient */ protected function getSubscribeClient() { - return $this->app - ->make(ReplicationInterface::class) - ->getSubscribeClient(); + return $this->channelManager->getSubscribeClient(); } /** * Get the publish client for the replication. * - * @return ReplicationInterface + * @return Mocks\LazyClient */ protected function getPublishClient() { - return $this->app - ->make(ReplicationInterface::class) - ->getPublishClient(); + return $this->channelManager->getPublishClient(); } /** @@ -376,4 +407,32 @@ abstract class TestCase extends BaseTestCase { file_put_contents(__DIR__.'/database.sqlite', null); } + + protected function runOnlyOnRedisReplication() + { + if ($this->replicationMode !== 'redis') { + $this->markTestSkipped('Skipped test because the replication mode is not set to Redis.'); + } + } + + protected function runOnlyOnLocalReplication() + { + if ($this->replicationMode !== 'local') { + $this->markTestSkipped('Skipped test because the replication mode is not set to Local.'); + } + } + + protected function skipOnRedisReplication() + { + if ($this->replicationMode === 'redis') { + $this->markTestSkipped('Skipped test because the replication mode is Redis.'); + } + } + + protected function skipOnLocalReplication() + { + if ($this->replicationMode === 'local') { + $this->markTestSkipped('Skipped test because the replication mode is Local.'); + } + } } diff --git a/tests/TestServiceProvider.php b/tests/TestServiceProvider.php index 958086e..c43ce45 100644 --- a/tests/TestServiceProvider.php +++ b/tests/TestServiceProvider.php @@ -1,6 +1,6 @@ expectException(HttpException::class); + $this->expectExceptionMessage('Invalid auth signature provided.'); + + $connection = new Mocks\Connection; + + $requestPath = '/apps/1234/events'; + + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'InvalidSecret', 'GET', $requestPath + ); + + $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $controller = app(TriggerEvent::class); + + $controller->onOpen($connection, $request); + } + + public function test_it_fires_the_event_to_public_channel() + { + $this->newActiveConnection(['public-channel']); + + $connection = new Mocks\Connection; + + $requestPath = '/apps/1234/events'; + + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'TestSecret', 'GET', $requestPath, [ + 'channels' => 'public-channel', + ], + ); + + $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $controller = app(TriggerEvent::class); + + $controller->onOpen($connection, $request); + + /** @var JsonResponse $response */ + $response = array_pop($connection->sentRawData); + + $this->assertSame([], json_decode($response->getContent(), true)); + + $this->statisticsCollector + ->getAppStatistics('1234') + ->then(function ($statistics) { + $this->assertEquals([ + 'peak_connections_count' => 1, + 'websocket_messages_count' => 1, + 'api_messages_count' => 1, + 'app_id' => '1234', + ], $statistic->toArray()); + }); + } + + public function test_it_fires_the_event_to_presence_channel() + { + $this->newPresenceConnection('presence-channel'); + + $connection = new Mocks\Connection; + + $requestPath = '/apps/1234/events'; + + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'TestSecret', 'GET', $requestPath, [ + 'channels' => 'presence-channel', + ], + ); + + $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $controller = app(TriggerEvent::class); + + $controller->onOpen($connection, $request); + + /** @var JsonResponse $response */ + $response = array_pop($connection->sentRawData); + + $this->assertSame([], json_decode($response->getContent(), true)); + + $this->statisticsCollector + ->getAppStatistics('1234') + ->then(function ($statistics) { + $this->assertEquals([ + 'peak_connections_count' => 1, + 'websocket_messages_count' => 1, + 'api_messages_count' => 1, + 'app_id' => '1234', + ], $statistic->toArray()); + }); + } + + public function test_it_fires_the_event_to_private_channel() + { + $this->newPresenceConnection('private-channel'); + + $connection = new Mocks\Connection; + + $requestPath = '/apps/1234/events'; + + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'TestSecret', 'GET', $requestPath, [ + 'channels' => 'private-channel', + ], + ); + + $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $controller = app(TriggerEvent::class); + + $controller->onOpen($connection, $request); + + /** @var JsonResponse $response */ + $response = array_pop($connection->sentRawData); + + $this->assertSame([], json_decode($response->getContent(), true)); + + $this->statisticsCollector + ->getAppStatistics('1234') + ->then(function ($statistics) { + $this->assertEquals([ + 'peak_connections_count' => 1, + 'websocket_messages_count' => 1, + 'api_messages_count' => 1, + 'app_id' => '1234', + ], $statistic->toArray()); + }); + } + + public function test_it_fires_event_across_servers() + { + $connection = new Mocks\Connection; + + $requestPath = '/apps/1234/events'; + + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'TestSecret', 'GET', $requestPath, [ + 'channels' => 'public-channel', + ], + ); + + $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $controller = app(TriggerEvent::class); + + $controller->onOpen($connection, $request); + + /** @var JsonResponse $response */ + $response = array_pop($connection->sentRawData); + + $this->assertSame([], json_decode($response->getContent(), true)); + + if (method_exists($this->channelManager, 'getPublishClient')) { + $this->channelManager + ->getPublishClient() + ->assertCalledWithArgs('publish', [ + $this->channelManager->getRedisKey('1234', 'public-channel'), + json_encode([ + 'channel' => 'public-channel', + 'event' => null, + 'data' => null, + 'appId' => '1234', + 'serverId' => $this->channelManager->getServerId(), + ]), + ]); + } + } +} diff --git a/tests/database/factories/UserFactory.php b/tests/database/factories/UserFactory.php index b6aaf0d..07d6e7b 100644 --- a/tests/database/factories/UserFactory.php +++ b/tests/database/factories/UserFactory.php @@ -12,7 +12,7 @@ use Illuminate\Support\Str; -$factory->define(\BeyondCode\LaravelWebSockets\Tests\Models\User::class, function () { +$factory->define(\BeyondCode\LaravelWebSockets\Test\Models\User::class, function () { return [ 'name' => 'Name'.Str::random(5), 'email' => Str::random(5).'@gmail.com',