diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..33dbc6b --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,18 @@ +codecov: + notify: + require_ci_to_pass: yes + +coverage: + precision: 2 + round: down + range: "70...100" + +status: + project: yes + patch: yes + changes: no + +comment: + layout: "reach, diff, flags, files, footer" + behavior: default + require_changes: no 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 aaed621..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:${{ 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 f423e5b..65e1146 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +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 276de5f..33c4550 100644 --- a/composer.json +++ b/composer.json @@ -1,12 +1,14 @@ { "name": "beyondcode/laravel-websockets", - "description": "An easy to use WebSocket server", + "description": "An easy to launch a Pusher-compatible WebSockets server for Laravel.", "keywords": [ "beyondcode", - "laravel-websockets" + "laravel-websockets", + "laravel", + "php" ], - "homepage": "https://github.com/beyondcode/laravel-websockets", "license": "MIT", + "homepage": "https://github.com/beyondcode/laravel-websockets", "authors": [ { "name": "Marcel Pociot", @@ -19,6 +21,11 @@ "email": "freek@spatie.be", "homepage": "https://spatie.be", "role": "Developer" + }, + { + "name": "Alex Renoki", + "homepage": "https://github.com/rennokki", + "role": "Developer" } ], "require": { @@ -27,42 +34,49 @@ "cboden/ratchet": "^0.4.1", "clue/buzz-react": "^2.5", "clue/redis-react": "^2.3", + "doctrine/dbal": "^2.0", "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", + "illuminate/broadcasting": "^6.3|^7.0|^8.0", + "illuminate/console": "^6.3|^7.0|^8.0", + "illuminate/http": "^6.3|^7.0|^8.0", + "illuminate/queue": "^6.3|^7.0|^8.0", + "illuminate/routing": "^6.3|^7.0|^8.0", + "illuminate/support": "^6.3|^7.0|^8.0", "pusher/pusher-php-server": "^3.0|^4.0", - "react/dns": "^1.1", + "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", + "laravel/legacy-factories": "^1.0.4", "mockery/mockery": "^1.3", - "orchestra/testbench": "3.8.*|^4.0|^5.0", + "orchestra/testbench-browser-kit": "^4.0|^5.0|^6.0", + "orchestra/database": "^4.0|^5.0|^6.0", "phpunit/phpunit": "^8.0|^9.0" }, + "suggest": { + "ext-pcntl": "Running the server needs pcntl to listen to command signals and soft-shutdown." + }, "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" }, "config": { "sort-packages": true }, + "minimum-stability": "dev", "extra": { "laravel": { "providers": [ diff --git a/config/websockets.php b/config/websockets.php index b7ffe7c..9bb34b4 100644 --- a/config/websockets.php +++ b/config/websockets.php @@ -42,21 +42,6 @@ return [ 'app' => \BeyondCode\LaravelWebSockets\Apps\ConfigAppManager::class, - /* - |-------------------------------------------------------------------------- - | Channel Manager - |-------------------------------------------------------------------------- - | - | When users subscribe or unsubscribe from specific channels, - | the connections are stored to keep track of any interaction with the - | WebSocket server. - | You can however add your own implementation that will help the store - | of the channels alongside their connections. - | - */ - - 'channel' => \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\ArrayChannelManager::class, - ], /* @@ -78,6 +63,7 @@ return [ [ 'id' => env('PUSHER_APP_ID'), 'name' => env('APP_NAME'), + 'host' => env('PUSHER_APP_HOST'), 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'path' => env('PUSHER_APP_PATH'), @@ -90,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' => env('WEBSOCKETS_REDIS_REPLICATION_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 @@ -144,101 +265,33 @@ return [ 'handlers' => [ - 'websocket' => \BeyondCode\LaravelWebSockets\WebSockets\WebSocketHandler::class, + 'websocket' => \BeyondCode\LaravelWebSockets\Server\WebSocketHandler::class, + + 'health' => \BeyondCode\LaravelWebSockets\Server\HealthHandler::class, + + 'trigger_event' => \BeyondCode\LaravelWebSockets\API\TriggerEvent::class, + + 'fetch_channels' => \BeyondCode\LaravelWebSockets\API\FetchChannels::class, + + 'fetch_channel' => \BeyondCode\LaravelWebSockets\API\FetchChannel::class, + + 'fetch_users' => \BeyondCode\LaravelWebSockets\API\FetchUsers::class, ], /* |-------------------------------------------------------------------------- - | Broadcasting Replication PubSub + | Promise Resolver |-------------------------------------------------------------------------- | - | 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. + | The promise resolver is a class that takes a input value and is + | able to make sure the PHP code runs async by using ->then(). You can + | use your own Promise Resolver. This is usually changed when you want to + | intercept values by the promises throughout the app, like in testing + | to switch from async to sync. | */ - 'replication' => [ - - 'driver' => env('LARAVEL_WEBSOCKETS_REPLICATION_DRIVER', 'local'), - - 'redis' => [ - - 'connection' => env('LARAVEL_WEBSOCKETS_REPLICATION_CONNECTION', 'default'), - - ], - - ], - - '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 Logger Handler - |-------------------------------------------------------------------------- - | - | The Statistics Logger will, by default, handle the incoming statistics, - | store them into an array and then store them into the database - | on each interval. - | - | You can opt-in to avoid any statistics storage by setting the logger - | to the built-in NullLogger. - | - */ - - 'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger::class, - // 'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\NullStatisticsLogger::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, - - ], + 'promise_resolver' => \React\Promise\FulfilledPromise::class, ]; diff --git a/database/migrations/0000_00_00_000000_rename_statistics_counters.php b/database/migrations/0000_00_00_000000_rename_statistics_counters.php new file mode 100644 index 0000000..70dbf79 --- /dev/null +++ b/database/migrations/0000_00_00_000000_rename_statistics_counters.php @@ -0,0 +1,36 @@ +renameColumn('peak_connection_count', 'peak_connections_count'); + $table->renameColumn('websocket_message_count', 'websocket_messages_count'); + $table->renameColumn('api_message_count', 'api_messages_count'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('websockets_statistics_entries', function (Blueprint $table) { + $table->renameColumn('peak_connections_count', 'peak_connection_count'); + $table->renameColumn('websocket_messages_count', 'websocket_message_count'); + $table->renameColumn('api_messages_count', 'api_message_count'); + }); + } +} diff --git a/docs/_index.md b/docs/_index.md index 183f7e6..7c504e5 100644 --- a/docs/_index.md +++ b/docs/_index.md @@ -1,4 +1,4 @@ --- packageName: Laravel Websockets githubUrl: https://github.com/beyondcode/laravel-websockets ---- \ No newline at end of file +--- diff --git a/docs/advanced-usage/app-providers.md b/docs/advanced-usage/app-providers.md index aca721d..77f4502 100644 --- a/docs/advanced-usage/app-providers.md +++ b/docs/advanced-usage/app-providers.md @@ -11,7 +11,7 @@ Depending on your setup, you might have your app configuration stored elsewhere > Make sure that you do **not** perform any IO blocking tasks in your `AppManager`, as they will interfere with the asynchronous WebSocket execution. -In order to create your custom `AppManager`, create a class that implements the `BeyondCode\LaravelWebSockets\Apps\AppManager` interface. +In order to create your custom `AppManager`, create a class that implements the `BeyondCode\LaravelWebSockets\Contracts\AppManager` interface. This is what it looks like: @@ -34,11 +34,11 @@ interface AppManager The following is an example AppManager that utilizes an Eloquent model: ```php -namespace App\Appmanagers; +namespace App\Managers; use App\Application; use BeyondCode\LaravelWebSockets\Apps\App; -use BeyondCode\LaravelWebSockets\Apps\AppManager; +use BeyondCode\LaravelWebSockets\Contracts\AppManager; class MyCustomAppManager implements AppManager { @@ -51,22 +51,22 @@ class MyCustomAppManager implements AppManager ->toArray(); } - public function findById($appId) : ? App + public function findById($appId) : ?App { return $this->normalize(Application::findById($appId)->toArray()); } - public function findByKey($appKey) : ? App + public function findByKey($appKey) : ?App { return $this->normalize(Application::findByKey($appKey)->toArray()); } - public function findBySecret($appSecret) : ? App + public function findBySecret($appSecret) : ?App { return $this->normalize(Application::findBySecret($appSecret)->toArray()); } - protected function normalize(?array $appAttributes) : ? App + protected function normalize(?array $appAttributes) : ?App { if (! $appAttributes) { return null; @@ -116,7 +116,5 @@ Once you have implemented your own AppManager, you need to set it in the `websoc 'app' => \App\Managers\MyCustomAppManager::class, - ... - ], ``` diff --git a/docs/advanced-usage/custom-websocket-handlers.md b/docs/advanced-usage/custom-websocket-handlers.md index b7653d6..71ebe60 100644 --- a/docs/advanced-usage/custom-websocket-handlers.md +++ b/docs/advanced-usage/custom-websocket-handlers.md @@ -15,13 +15,13 @@ Once implemented, you will have a class that looks something like this: ```php namespace App; +use Exception; use Ratchet\ConnectionInterface; use Ratchet\RFC6455\Messaging\MessageInterface; use Ratchet\WebSocket\MessageComponentInterface; class MyCustomWebSocketHandler implements MessageComponentInterface { - public function onOpen(ConnectionInterface $connection) { // TODO: Implement onOpen() method. @@ -32,7 +32,7 @@ class MyCustomWebSocketHandler implements MessageComponentInterface // TODO: Implement onClose() method. } - public function onError(ConnectionInterface $connection, \Exception $e) + public function onError(ConnectionInterface $connection, Exception $e) { // TODO: Implement onError() method. } @@ -48,12 +48,12 @@ In the class itself you have full control over all the lifecycle events of your The only part missing is, that you will need to tell our WebSocket server to load this handler at a specific route endpoint. This can be achieved using the `WebSocketsRouter` facade. -This class takes care of registering the routes with the actual webSocket server. You can use the `webSocket` method to define a custom WebSocket endpoint. The method needs two arguments: the path where the WebSocket handled should be available and the fully qualified classname of the WebSocket handler class. +This class takes care of registering the routes with the actual webSocket server. You can use the `get` method to define a custom WebSocket endpoint. The method needs two arguments: the path where the WebSocket handled should be available and the fully qualified classname of the WebSocket handler class. This could, for example, be done inside your `routes/web.php` file. ```php -WebSocketsRouter::webSocket('/my-websocket', \App\MyCustomWebSocketHandler::class); +WebSocketsRouter::get('/my-websocket', \App\MyCustomWebSocketHandler::class); ``` Once you've added the custom WebSocket route, be sure to restart our WebSocket server for the changes to take place. diff --git a/docs/advanced-usage/dispatched-events.md b/docs/advanced-usage/dispatched-events.md new file mode 100644 index 0000000..be5e095 --- /dev/null +++ b/docs/advanced-usage/dispatched-events.md @@ -0,0 +1,82 @@ +--- +title: Dispatched Events +order: 5 +--- + +# Dispatched Events + +Laravel WebSockets takes advantage of Laravel's Event dispatching observer, in a way that you can handle in-server events outside of it. + +For example, you can listen for events like when a new connection establishes or when an user joins a presence channel. + +## Events + +Below you will find a list of dispatched events: + +- `BeyondCode\LaravelWebSockets\Events\NewConnection` - when a connection successfully establishes on the server +- `BeyondCode\LaravelWebSockets\Events\ConnectionClosed` - when a connection leaves the server +- `BeyondCode\LaravelWebSockets\Events\SubscribedToChannel` - when a connection subscribes to a specific channel +- `BeyondCode\LaravelWebSockets\Events\UnsubscribedFromChannel` - when a connection unsubscribes from a specific channel +- `BeyondCode\LaravelWebSockets\Events\WebSocketMessageReceived` - when the server receives a message +- `BeyondCode\LaravelWebSockets\EventsConnectionPonged` - when a connection pings to the server that it is still alive + +## Queued Listeners + +Because the default Redis connection (either PhpRedis or Predis) is a blocking I/O method and can cause problems with the server speed and availability, you might want to check the [Non-Blocking Queue Driver](non-blocking-queue-driver.md) documentation that helps you create the Async Redis queue driver that is going to fix the Blocking I/O issue. + +If set up, you can use the `async-redis` queue driver in your listeners: + +```php + [ + App\Listeners\HandleNewConnections::class, + ], +]; +``` diff --git a/docs/advanced-usage/non-blocking-queue-driver.md b/docs/advanced-usage/non-blocking-queue-driver.md new file mode 100644 index 0000000..98ed10d --- /dev/null +++ b/docs/advanced-usage/non-blocking-queue-driver.md @@ -0,0 +1,30 @@ +--- +title: Non-Blocking Queue Driver +order: 4 +--- + +# Non-Blocking Queue Driver + +In Laravel, he default Redis connection also interacts with the queues. Since you might want to dispatch jobs on Redis from the server, you can encounter an anti-pattern of using a blocking I/O connection (like PhpRedis or PRedis) within the WebSockets server. + +To solve this issue, you can configure the built-in queue driver that uses the Async Redis connection when it's possible, like within the WebSockets server. It's highly recommended to switch your queue to it if you are going to use the queues within the server controllers, for example. + +Add the `async-redis` queue driver to your list of connections. The configuration parameters are compatible with the default `redis` driver: + +```php +'connections' => [ + 'async-redis' => [ + 'driver' => 'async-redis', + 'connection' => env('WEBSOCKETS_REDIS_REPLICATION_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => 90, + 'block_for' => null, + ], +] +``` + +Also, make sure that the default queue driver is set to `async-redis`: + +``` +QUEUE_CONNECTION=async-redis +``` diff --git a/docs/advanced-usage/webhooks.md b/docs/advanced-usage/webhooks.md index ca4799e..2df8e92 100644 --- a/docs/advanced-usage/webhooks.md +++ b/docs/advanced-usage/webhooks.md @@ -36,7 +36,7 @@ class WebSocketHandler extends BaseWebSocketHandler // Run code on close. // $connection->app contains the app details // $this->channelManager is accessible - }**** + } } ``` diff --git a/docs/basic-usage/pusher.md b/docs/basic-usage/pusher.md index cc0589e..6d72a2d 100644 --- a/docs/basic-usage/pusher.md +++ b/docs/basic-usage/pusher.md @@ -13,7 +13,7 @@ To make it clear, the package does not restrict connections numbers or depend on To make use of the Laravel WebSockets package in combination with Pusher, you first need to install the official Pusher PHP SDK. -If you are not yet familiar with the concept of Broadcasting in Laravel, please take a look at the [Laravel documentation](https://laravel.com/docs/6.0/broadcasting). +If you are not yet familiar with the concept of Broadcasting in Laravel, please take a look at the [Laravel documentation](https://laravel.com/docs/8.0/broadcasting). ```bash composer require pusher/pusher-php-server "~4.0" @@ -40,9 +40,13 @@ To do this, you should add the `host` and `port` configuration key to your `conf 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), 'encrypted' => true, - 'host' => '127.0.0.1', - 'port' => 6001, - 'scheme' => 'http', + 'host' => env('PUSHER_APP_HOST', '127.0.0.1'), + 'port' => env('PUSHER_APP_PORT', 6001), + 'scheme' => env('PUSHER_APP_SCHEME', 'http'), + 'curl_options' => [ + CURLOPT_SSL_VERIFYHOST => 0, + CURLOPT_SSL_VERIFYPEER => 0, + ], ], ], ``` @@ -95,8 +99,8 @@ To enable or disable the statistics for one of your apps, you can modify the `en ## Usage with Laravel Echo -The Laravel WebSockets package integrates nicely into [Laravel Echo](https://laravel.com/docs/6.0/broadcasting#receiving-broadcasts) to integrate into your frontend application and receive broadcasted events. -If you are new to Laravel Echo, be sure to take a look at the [official documentation](https://laravel.com/docs/6.0/broadcasting#receiving-broadcasts). +The Laravel WebSockets package integrates nicely into [Laravel Echo](https://laravel.com/docs/8.0/broadcasting#receiving-broadcasts) to integrate into your frontend application and receive broadcasted events. +If you are new to Laravel Echo, be sure to take a look at the [official documentation](https://laravel.com/docs/8.0/broadcasting#receiving-broadcasts). To make Laravel Echo work with Laravel WebSockets, you need to make some minor configuration changes when working with Laravel Echo. Add the `wsHost` and `wsPort` parameters and point them to your Laravel WebSocket server host and port. @@ -107,7 +111,7 @@ When using Laravel WebSockets in combination with a custom SSL certificate, be s ::: ```js -import Echo from "laravel-echo" +import Echo from 'laravel-echo'; window.Pusher = require('pusher-js'); @@ -122,4 +126,4 @@ window.Echo = new Echo({ }); ``` -Now you can use all Laravel Echo features in combination with Laravel WebSockets, such as [Presence Channels](https://laravel.com/docs/7.x/broadcasting#presence-channels), [Notifications](https://laravel.com/docs/7.x/broadcasting#notifications) and [Client Events](https://laravel.com/docs/7.x/broadcasting#client-events). +Now you can use all Laravel Echo features in combination with Laravel WebSockets, such as [Presence Channels](https://laravel.com/docs/8.x/broadcasting#presence-channels), [Notifications](https://laravel.com/docs/8.x/broadcasting#notifications) and [Client Events](https://laravel.com/docs/8.x/broadcasting#client-events). diff --git a/docs/basic-usage/restarting.md b/docs/basic-usage/restarting.md index f4b19fd..56c5539 100644 --- a/docs/basic-usage/restarting.md +++ b/docs/basic-usage/restarting.md @@ -7,7 +7,7 @@ order: 4 If you use Supervisor to keep your server alive, you might want to restart it just like `queue:restart` does. -To do so, consider using the `websockets:restart`. In a maximum of 10 seconds, the server will be restarted automatically. +To do so, consider using the `websockets:restart`. In a maximum of 10 seconds since issuing the command, the server will be restarted. ```bash php artisan websockets:restart diff --git a/docs/basic-usage/ssl.md b/docs/basic-usage/ssl.md index c51ba28..3309243 100644 --- a/docs/basic-usage/ssl.md +++ b/docs/basic-usage/ssl.md @@ -10,6 +10,7 @@ Since most of the web's traffic is going through HTTPS, it's also crucial to sec ## Configuration The SSL configuration takes place in your `config/websockets.php` file. + The default configuration has a SSL section that looks like this: ```php @@ -31,6 +32,7 @@ The default configuration has a SSL section that looks like this: ``` But this is only a subset of all the available configuration options. + This packages makes use of the official PHP [SSL context options](http://php.net/manual/en/context.ssl.php). So if you find yourself in the need of adding additional configuration settings, take a look at the PHP documentation and simply add the configuration parameters that you need. @@ -64,7 +66,13 @@ window.Echo = new Echo({ ## Server configuration -When broadcasting events from your Laravel application to the WebSocket server, you also need to tell Laravel to make use of HTTPS instead of HTTP. You can do this by setting the `scheme` option in your `config/broadcasting.php` file to `https`: +When broadcasting events from your Laravel application to the WebSocket server, you also need to tell Laravel to make use of HTTPS instead of HTTP. You can do this by setting the `PUSHER_APP_SCHEME` variable to `https` + +```env +PUSHER_APP_SCHEME=https +``` + +Your connection from `config/broadcasting.php` would look like this: ```php 'pusher' => [ @@ -75,9 +83,9 @@ When broadcasting events from your Laravel application to the WebSocket server, 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), 'encrypted' => true, - 'host' => '127.0.0.1', - 'port' => 6001, - 'scheme' => 'https', + 'host' => env('PUSHER_APP_HOST', '127.0.0.1'), + 'port' => env('PUSHER_APP_PORT', 6001), + 'scheme' => env('PUSHER_APP_SCHEME', 'http'), ], ], ``` @@ -100,7 +108,7 @@ Make sure that you replace `YOUR-USERNAME` with your Mac username and `VALET-SIT 'capath' => env('LARAVEL_WEBSOCKETS_SSL_CA', null), - 'local_pk' => 'local_pk' => '/Users/YOUR-USERNAME/.config/valet/Certificates/VALET-SITE.TLD.key', + 'local_pk' => '/Users/YOUR-USERNAME/.config/valet/Certificates/VALET-SITE.TLD.key', 'passphrase' => env('LARAVEL_WEBSOCKETS_SSL_PASSPHRASE', null), @@ -124,13 +132,13 @@ You also need to disable SSL verification. 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), 'encrypted' => true, - 'host' => '127.0.0.1', - 'port' => 6001, - 'scheme' => 'https', + 'host' => env('PUSHER_APP_HOST', '127.0.0.1'), + 'port' => env('PUSHER_APP_PORT', 6001), + 'scheme' => env('PUSHER_APP_SCHEME', 'http'), 'curl_options' => [ CURLOPT_SSL_VERIFYHOST => 0, CURLOPT_SSL_VERIFYPEER => 0, - ] + ], ], ], ``` @@ -199,7 +207,7 @@ server { location / { try_files /nonexistent @$type; } - + location @web { try_files $uri $uri/ /index.php?$query_string; } @@ -264,28 +272,20 @@ You know you've reached this limit of your Nginx error logs contain similar mess Remember to restart your Nginx after you've modified the `worker_connections`. -### Example using Caddy +### Example using Caddy v2 -[Caddy](https://caddyserver.com) can also be used to automatically obtain a TLS certificate from Let's Encrypt and terminate TLS before proxying to your echo server. +[Caddy](https://caddyserver.com) can also be used to automatically obtain a TLS certificate from Let's Encrypt and terminate TLS before proxying to your websocket server. An example configuration would look like this: ``` socket.yourapp.tld { - rewrite / { - if {>Connection} has Upgrade - if {>Upgrade} is websocket - to /websocket-proxy/{path}?{query} + @ws { + header Connection *Upgrade* + header Upgrade websocket } - - proxy /websocket-proxy 127.0.0.1:6001 { - without /special-websocket-url - transparent - websocket - } - - tls youremail.com + reverse_proxy @ws 127.0.0.1:6001 } ``` -Note the `to /websocket-proxy`, this is a dummy path to allow the `proxy` directive to only proxy on websocket connections. This should be a path that will never be used by your application's routing. Also, note that you should change `127.0.0.1` to the hostname of your websocket server. For example, if you're running in a Docker environment, this might be the container name of your websocket server. +Note that you should change `127.0.0.1` to the hostname of your websocket server. For example, if you're running in a Docker environment, this might be the container name of your websocket server. diff --git a/docs/debugging/dashboard.md b/docs/debugging/dashboard.md index 57f50e6..bba0551 100644 --- a/docs/debugging/dashboard.md +++ b/docs/debugging/dashboard.md @@ -71,35 +71,12 @@ protected function schedule(Schedule $schedule) Each app contains an `enable_statistics` that defines wether that app generates statistics or not. The statistics are being stored for the `interval_in_seconds` seconds and then they are inserted in the database. -However, to disable it entirely and void any incoming statistic, you can uncomment the following line in the config: +However, to disable it entirely and void any incoming statistic, you can call `--disable-statistics` when running the server command: -```php -/* -|-------------------------------------------------------------------------- -| Statistics Logger Handler -|-------------------------------------------------------------------------- -| -| The Statistics Logger will, by default, handle the incoming statistics, -| store them into an array and then store them into the database -| on each interval. -| -| You can opt-in to avoid any statistics storage by setting the logger -| to the built-in NullLogger. -| -*/ - -// 'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger::class, -'logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\NullStatisticsLogger::class, // use the `NullStatisticsLogger` instead +```bash +php artisan websockets:serve --disable-statistics ``` -## Custom Statistics Drivers - -By default, the package comes with a few drivers like the Database driver which stores the data into the database. - -You should add your custom drivers under the `statistics` key in `websockets.php` and create a driver class that implements the `\BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver` interface. - -Take a quick look at the `\BeyondCode\LaravelWebSockets\Statistics\Drivers\DatabaseDriver` driver to see how to perform your integration. - ## Event Creator The dashboard also comes with an easy-to-use event creator, that lets you manually send events to your channels. diff --git a/docs/faq/scaling.md b/docs/faq/scaling.md index aa19abd..b5033f0 100644 --- a/docs/faq/scaling.md +++ b/docs/faq/scaling.md @@ -16,3 +16,7 @@ Here is another benchmark that was run on a 2GB Digital Ocean droplet with 2 CPU ![Benchmark](/img/simultaneous_users_2gb.png) Make sure to take a look at the [Deployment Tips](/docs/laravel-websockets/faq/deploying) to find out how to improve your specific setup. + +# Horizontal Scaling + +When deploying to multi-node environments, you will notice that the server won't behave correctly. Check [Horizontal Scaling](../horizontal-scaling/getting-started.md) section. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 824489b..5d24d7d 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -21,7 +21,7 @@ php artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsSe # Statistics -This package comes with a migration to store statistic information while running your WebSocket server. For more info, check the [Debug Dashboard](../debugging/dashboard.md) section. +This package comes with migrations to store statistic information while running your WebSocket server. For more info, check the [Debug Dashboard](../debugging/dashboard.md) section. You can publish the migration file using: diff --git a/docs/getting-started/introduction.md b/docs/getting-started/introduction.md index e061c8a..0e5050a 100644 --- a/docs/getting-started/introduction.md +++ b/docs/getting-started/introduction.md @@ -4,9 +4,10 @@ order: 1 --- # Laravel WebSockets 🛰 + WebSockets for Laravel. Done right. -Laravel WebSockets is a package for Laravel 5.7 and up that will get your application started with WebSockets in no-time! It has a drop-in Pusher API replacement, has a debug dashboard, realtime statistics and even allows you to create custom WebSocket controllers. +Laravel WebSockets is a package for Laravel that will get your application started with WebSockets in no-time! It has a drop-in Pusher API replacement, has a debug dashboard, realtime statistics and even allows you to create custom WebSocket controllers. Once installed, you can start it with one simple command: @@ -18,4 +19,4 @@ php artisan websockets:serve If you want to know how all of it works under the hood, we wrote an in-depth [blogpost](https://murze.be/introducing-laravel-websockets-an-easy-to-use-websocket-server-implemented-in-php) about it. -To help you get started, you can also take a look at the [demo repository](https://github.com/beyondcode/laravel-websockets-demo), that implements a basic Chat built with this package. \ No newline at end of file +To help you get started, you can also take a look at the [demo repository](https://github.com/beyondcode/laravel-websockets-demo), that implements a basic Chat built with this package. diff --git a/docs/horizontal-scaling/getting-started.md b/docs/horizontal-scaling/getting-started.md index 9033aff..1bb3ab4 100644 --- a/docs/horizontal-scaling/getting-started.md +++ b/docs/horizontal-scaling/getting-started.md @@ -15,54 +15,20 @@ For example, Redis does a great job by encapsulating the both the way of notifyi ## Configure the replication -To enable the replication, simply change the `replication.driver` name in the `websockets.php` file: +To enable the replication, simply change the `replication.mode` name in the `websockets.php` file: ```php 'replication' => [ - 'driver' => 'redis', + 'mode' => 'redis', ... ], ``` +Now, when your app broadcasts the message, it will make sure the connection reaches other servers which are under the same load balancer. + The available drivers for replication are: - [Redis](redis) - -## Configure the Broadcasting driver - -Laravel WebSockets comes with an additional `websockets` broadcaster driver that accepts configurations like the Pusher driver, but will make sure the broadcasting will work across all websocket servers: - -```php -'connections' => [ - 'pusher' => [ - ... - ], - - 'websockets' => [ - 'driver' => 'websockets', - 'key' => env('PUSHER_APP_KEY'), - 'secret' => env('PUSHER_APP_SECRET'), - 'app_id' => env('PUSHER_APP_ID'), - 'options' => [ - 'cluster' => env('PUSHER_APP_CLUSTER'), - 'encrypted' => true, - 'host' => '127.0.0.1', - 'port' => 6001, - 'curl_options' => [ - CURLOPT_SSL_VERIFYHOST => 0, - CURLOPT_SSL_VERIFYPEER => 0, - ], - ], - ], -``` - -Make sure to change the `BROADCAST_DRIVER`: - -``` -BROADCAST_DRIVER=websockets -``` - -Now, when your app broadcasts the message, it will make sure the connection reaches other servers which are under the same load balancer. diff --git a/docs/horizontal-scaling/redis.md b/docs/horizontal-scaling/redis.md index ee6c758..4f63835 100644 --- a/docs/horizontal-scaling/redis.md +++ b/docs/horizontal-scaling/redis.md @@ -1,16 +1,20 @@ --- -title: Redis +title: Redis Mode order: 2 --- -## Configure the Redis driver +# Redis Mode -To enable the replication, simply change the `replication.driver` name in the `websockets.php` file to `redis`: +Redis has the powerful ability to act both as a key-value store and as a PubSub service. This way, the connected servers will communicate between them whenever a message hits the server, so you can scale out to any amount of servers while preserving the WebSockets functionalities. + +## Configure Redis mode + +To enable the replication, simply change the `replication.mode` name in the `websockets.php` file to `redis`: ```php 'replication' => [ - 'driver' => 'redis', + 'mode' => 'redis', ... @@ -22,16 +26,17 @@ You can set the connection name to the Redis database under `redis`: ```php 'replication' => [ - ... + 'modes' => - 'redis' => [ + 'redis' => [ - 'connection' => 'default', + 'connection' => 'default', + + ], ], ], ``` -The connections can be found in your `config/database.php` file, under the `redis` key. It defaults to connection `default`. - +The connections can be found in your `config/database.php` file, under the `redis` key. 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 1121fad..e9704e6 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -252,7 +252,7 @@ form: { channel: null, event: null, - data: null, + data: {}, }, logs: [], }, @@ -261,15 +261,15 @@ }, destroyed () { if (this.refreshTicker) { - this.clearRefreshInterval(); + this.stopRefreshInterval(); } }, watch: { connected (newVal) { - newVal ? this.startRefreshInterval() : this.clearRefreshInterval(); + newVal ? this.startRefreshInterval() : this.stopRefreshInterval(); }, autoRefresh (newVal) { - newVal ? this.startRefreshInterval() : this.clearRefreshInterval(); + newVal ? this.startRefreshInterval() : this.stopRefreshInterval(); }, }, methods: { @@ -314,7 +314,7 @@ }); this.pusher.connection.bind('error', event => { - if (event.error.data.code === 4100) { + if (event.data.code === 4100) { this.connected = false; this.logs = []; this.chart = null; @@ -347,14 +347,14 @@ name: '# Peak Connections' }, { - x: data.websocket_message_count.x, - y: data.websocket_message_count.y, + x: data.websocket_messages_count.x, + y: data.websocket_messages_count.y, type: 'bar', name: '# Websocket Messages' }, { - x: data.api_message_count.x, - y: data.api_message_count.y, + x: data.api_messages_count.x, + y: data.api_messages_count.y, type: 'bar', name: '# API Messages' }, @@ -395,9 +395,9 @@ let payload = { _token: '{{ csrf_token() }}', + appId: this.app.id, key: this.app.key, secret: this.app.secret, - appId: this.app.id, channel: this.form.channel, event: this.form.event, data: JSON.stringify(this.form.data), @@ -405,13 +405,7 @@ axios .post('/event', payload) - .then(() => { - this.form = { - channel: null, - event: null, - data: null, - }; - }) + .then(() => {}) .catch(err => { alert('Error sending event.'); }) @@ -430,10 +424,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 89% rename from src/HttpApi/Controllers/Controller.php rename to src/API/Controller.php index 5a030ef..74267de 100644 --- a/src/HttpApi/Controllers/Controller.php +++ b/src/API/Controller.php @@ -1,10 +1,10 @@ sendAndClose($connection, $response); } @@ -233,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()); @@ -247,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..ddd39cc --- /dev/null +++ b/src/API/FetchChannels.php @@ -0,0 +1,77 @@ +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..5bb6738 --- /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 = [ + 'event' => $request->name, + 'channel' => $channelName, + 'data' => $request->data, + ]; + + if ($channel) { + $channel->broadcastLocallyToEveryoneExcept( + (object) $payload, + $request->socket_id, + $request->appId + ); + } + + $this->channelManager->broadcastAcrossServers( + $request->appId, $request->socket_id, $channelName, (object) $payload + ); + + StatisticsCollector::apiMessage($request->appId); + + DashboardLogger::log($request->appId, DashboardLogger::TYPE_API_MESSAGE, [ + 'event' => $request->name, + 'channel' => $channelName, + 'payload' => $request->data, + ]); + } + + return $request->json()->all(); + } +} diff --git a/src/Apps/App.php b/src/Apps/App.php index ae23f4d..19d10f6 100644 --- a/src/Apps/App.php +++ b/src/Apps/App.php @@ -2,11 +2,11 @@ namespace BeyondCode\LaravelWebSockets\Apps; -use BeyondCode\LaravelWebSockets\Exceptions\InvalidApp; +use BeyondCode\LaravelWebSockets\Contracts\AppManager; class App { - /** @var int */ + /** @var string|int */ public $id; /** @var string */ @@ -39,7 +39,7 @@ class App /** * Find the app by id. * - * @param mixed $appId + * @param string|int $appId * @return \BeyondCode\LaravelWebSockets\Apps\App|null */ public static function findById($appId) @@ -50,7 +50,7 @@ class App /** * Find the app by app key. * - * @param mixed $appId + * @param string $appKey * @return \BeyondCode\LaravelWebSockets\Apps\App|null */ public static function findByKey($appKey): ?self @@ -61,7 +61,7 @@ class App /** * Find the app by app secret. * - * @param mixed $appId + * @param string $appSecret * @return \BeyondCode\LaravelWebSockets\Apps\App|null */ public static function findBySecret($appSecret): ?self @@ -72,22 +72,13 @@ class App /** * Initialize the Web Socket app instance. * - * @param mixed $appId - * @param mixed $key - * @param mixed $secret + * @param string|int $appId + * @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 3136ad6..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(); } @@ -38,46 +40,40 @@ class ConfigAppManager implements AppManager /** * Get app by id. * - * @param mixed $appId + * @param string|int $appId * @return \BeyondCode\LaravelWebSockets\Apps\App|null */ public function findById($appId): ?App { - $appAttributes = $this - ->apps - ->firstWhere('id', $appId); - - return $this->instantiate($appAttributes); + return $this->convertIntoApp( + $this->apps->firstWhere('id', $appId) + ); } /** * Get app by app key. * - * @param mixed $appKey + * @param string $appKey * @return \BeyondCode\LaravelWebSockets\Apps\App|null */ public function findByKey($appKey): ?App { - $appAttributes = $this - ->apps - ->firstWhere('key', $appKey); - - return $this->instantiate($appAttributes); + return $this->convertIntoApp( + $this->apps->firstWhere('key', $appKey) + ); } /** * Get app by secret. * - * @param mixed $appSecret + * @param string $appSecret * @return \BeyondCode\LaravelWebSockets\Apps\App|null */ 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..ad01f7a --- /dev/null +++ b/src/ChannelManagers/LocalChannelManager.php @@ -0,0 +1,521 @@ +store = new ArrayStore; + } + + /** + * Find the channel by app & name. + * + * @param string|int $appId + * @param string $channel + * @return null|BeyondCode\LaravelWebSockets\Channels\Channel + */ + public function find($appId, string $channel) + { + return $this->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 the local connections, regardless of the channel + * they are connected to. + * + * @return \React\Promise\PromiseInterface + */ + public function getLocalConnections(): PromiseInterface + { + $connections = collect($this->channels) + ->map(function ($channelsWithConnections, $appId) { + return collect($channelsWithConnections)->values(); + }) + ->values()->collapse() + ->map(function ($channel) { + return collect($channel->getConnections()); + }) + ->values()->collapse() + ->toArray(); + + return Helpers::createFulfilledPromise($connections); + } + + /** + * 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 Helpers::createFulfilledPromise( + $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 PromiseInterface[bool] + */ + public function unsubscribeFromAllChannels(ConnectionInterface $connection): PromiseInterface + { + if (! isset($connection->app)) { + return new FuilfilledPromise(false); + } + + $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]); + } + }); + + return Helpers::createFulfilledPromise(true); + } + + /** + * Subscribe the connection to a specific channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param string $channelName + * @param stdClass $payload + * @return PromiseInterface[bool] + */ + public function subscribeToChannel(ConnectionInterface $connection, string $channelName, stdClass $payload): PromiseInterface + { + $channel = $this->findOrCreate($connection->app->id, $channelName); + + return Helpers::createFulfilledPromise( + $channel->subscribe($connection, $payload) + ); + } + + /** + * Unsubscribe the connection from the channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param string $channelName + * @param stdClass $payload + * @return PromiseInterface[bool] + */ + public function unsubscribeFromChannel(ConnectionInterface $connection, string $channelName, stdClass $payload): PromiseInterface + { + $channel = $this->findOrCreate($connection->app->id, $channelName); + + return Helpers::createFulfilledPromise( + $channel->unsubscribe($connection, $payload) + ); + } + + /** + * Subscribe the connection to a specific channel, returning + * a promise containing the amount of connections. + * + * @param string|int $appId + * @return PromiseInterface[int] + */ + public function subscribeToApp($appId): PromiseInterface + { + return Helpers::createFulfilledPromise(0); + } + + /** + * Unsubscribe the connection from the channel, returning + * a promise containing the amount of connections after decrement. + * + * @param string|int $appId + * @return PromiseInterface[int] + */ + public function unsubscribeFromApp($appId): PromiseInterface + { + return Helpers::createFulfilledPromise(0); + } + + /** + * Get the connections count on the app + * for the current server instance. + * + * @param string|int $appId + * @param string|null $channelName + * @return PromiseInterface[int] + */ + 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 PromiseInterface[int] + */ + 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|null $socketId + * @param string $channel + * @param stdClass $payload + * @param string|null $serverId + * @return PromiseInterface[bool] + */ + public function broadcastAcrossServers($appId, ?string $socketId, string $channel, stdClass $payload, string $serverId = null): PromiseInterface + { + return Helpers::createFulfilledPromise(true); + } + + /** + * Handle the user when it joined a presence channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param stdClass $user + * @param string $channel + * @param stdClass $payload + * @return PromiseInterface[bool] + */ + public function userJoinedPresenceChannel(ConnectionInterface $connection, stdClass $user, string $channel, stdClass $payload): PromiseInterface + { + $this->users["{$connection->app->id}:{$channel}"][$connection->socketId] = json_encode($user); + $this->userSockets["{$connection->app->id}:{$channel}:{$user->user_id}"][] = $connection->socketId; + + return Helpers::createFulfilledPromise(true); + } + + /** + * Handle the user when it left a presence channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param stdClass $user + * @param string $channel + * @param stdClass $payload + * @return PromiseInterface[bool] + */ + public function userLeftPresenceChannel(ConnectionInterface $connection, stdClass $user, string $channel): PromiseInterface + { + unset($this->users["{$connection->app->id}:{$channel}"][$connection->socketId]); + + $deletableSocketKey = array_search( + $connection->socketId, + $this->userSockets["{$connection->app->id}:{$channel}:{$user->user_id}"] + ); + + if ($deletableSocketKey !== false) { + unset($this->userSockets["{$connection->app->id}:{$channel}:{$user->user_id}"][$deletableSocketKey]); + + if (count($this->userSockets["{$connection->app->id}:{$channel}:{$user->user_id}"]) === 0) { + unset($this->userSockets["{$connection->app->id}:{$channel}:{$user->user_id}"]); + } + } + + return Helpers::createFulfilledPromise(true); + } + + /** + * 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); + })->unique('user_id')->toArray(); + + return Helpers::createFulfilledPromise($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 Helpers::createFulfilledPromise($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 Helpers::createFulfilledPromise($results); + } + + /** + * Get the socket IDs for a presence channel member. + * + * @param string|int $userId + * @param string|int $appId + * @param string $channelName + * @return \React\Promise\PromiseInterface + */ + public function getMemberSockets($userId, $appId, $channelName): PromiseInterface + { + return Helpers::createFulfilledPromise( + $this->userSockets["{$appId}:{$channelName}:{$userId}"] ?? [] + ); + } + + /** + * Keep tracking the connections availability when they pong. + * + * @param \Ratchet\ConnectionInterface $connection + * @return PromiseInterface[bool] + */ + public function connectionPonged(ConnectionInterface $connection): PromiseInterface + { + $connection->lastPongedAt = Carbon::now(); + + return $this->updateConnectionInChannels($connection); + } + + /** + * Remove the obsolete connections that didn't ponged in a while. + * + * @return PromiseInterface[bool] + */ + public function removeObsoleteConnections(): PromiseInterface + { + if (! $this->lock()->acquire()) { + return Helpers::createFulfilledPromise(false); + } + + $this->getLocalConnections()->then(function ($connections) { + foreach ($connections as $connection) { + $differenceInSeconds = $connection->lastPongedAt->diffInSeconds(Carbon::now()); + + if ($differenceInSeconds > 120) { + $this->unsubscribeFromAllChannels($connection); + } + } + }); + + return Helpers::createFulfilledPromise( + $this->lock()->release() + ); + } + + /** + * Update the connection in all channels. + * + * @param ConnectionInterface $connection + * @return PromiseInterface[bool] + */ + public function updateConnectionInChannels($connection): PromiseInterface + { + return $this->getLocalChannels($connection->app->id) + ->then(function ($channels) use ($connection) { + foreach ($channels as $channel) { + if ($channel->hasConnection($connection)) { + $channel->saveConnection($connection); + } + } + + return true; + }); + } + + /** + * Mark the current instance as unable to accept new connections. + * + * @return $this + */ + public function declineNewConnections() + { + $this->acceptsNewConnections = false; + + return $this; + } + + /** + * Check if the current server instance + * accepts new connections. + * + * @return bool + */ + public function acceptsNewConnections(): bool + { + return $this->acceptsNewConnections; + } + + /** + * 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; + } + + /** + * Get a new ArrayLock instance to avoid race conditions. + * + * @return \Illuminate\Cache\CacheLock + */ + protected function lock() + { + return new ArrayLock($this->store, static::$lockName, 0); + } +} diff --git a/src/ChannelManagers/RedisChannelManager.php b/src/ChannelManagers/RedisChannelManager.php new file mode 100644 index 0000000..51a6d59 --- /dev/null +++ b/src/ChannelManagers/RedisChannelManager.php @@ -0,0 +1,797 @@ +loop = $loop; + + $this->redis = Redis::connection( + config('websockets.replication.modes.redis.connection', 'default') + ); + + $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 the local connections, regardless of the channel + * they are connected to. + * + * @return \React\Promise\PromiseInterface + */ + public function getLocalConnections(): PromiseInterface + { + return parent::getLocalConnections(); + } + + /** + * 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->publishClient->smembers( + $this->getRedisKey($appId, null, ['channels']) + ); + } + + /** + * Remove connection from all channels. + * + * @param \Ratchet\ConnectionInterface $connection + * @return PromiseInterface[bool] + */ + public function unsubscribeFromAllChannels(ConnectionInterface $connection): PromiseInterface + { + return $this->getGlobalChannels($connection->app->id) + ->then(function ($channels) use ($connection) { + foreach ($channels as $channel) { + $this->unsubscribeFromChannel($connection, $channel, new stdClass); + } + }) + ->then(function () use ($connection) { + return parent::unsubscribeFromAllChannels($connection); + }); + } + + /** + * Subscribe the connection to a specific channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param string $channelName + * @param stdClass $payload + * @return PromiseInterface[bool] + */ + public function subscribeToChannel(ConnectionInterface $connection, string $channelName, stdClass $payload): PromiseInterface + { + return $this->subscribeToTopic($connection->app->id, $channelName) + ->then(function () use ($connection) { + return $this->addConnectionToSet($connection, Carbon::now()); + }) + ->then(function () use ($connection, $channelName) { + return $this->addChannelToSet($connection->app->id, $channelName); + }) + ->then(function () use ($connection, $channelName) { + return $this->incrementSubscriptionsCount($connection->app->id, $channelName, 1); + }) + ->then(function () use ($connection, $channelName, $payload) { + return parent::subscribeToChannel($connection, $channelName, $payload); + }); + } + + /** + * Unsubscribe the connection from the channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param string $channelName + * @param stdClass $payload + * @return PromiseInterface[bool] + */ + public function unsubscribeFromChannel(ConnectionInterface $connection, string $channelName, stdClass $payload): PromiseInterface + { + return $this->getGlobalConnectionsCount($connection->app->id, $channelName) + ->then(function ($count) use ($connection, $channelName) { + if ($count === 0) { + // Make sure to not stay subscribed to the PubSub topic + // if there are no connections. + $this->unsubscribeFromTopic($connection->app->id, $channelName); + } + + $this->decrementSubscriptionsCount($connection->app->id, $channelName) + ->then(function ($count) use ($connection, $channelName) { + // If the total connections count gets to 0 after unsubscribe, + // try again to check & unsubscribe from the PubSub topic if needed. + if ($count < 1) { + $this->unsubscribeFromTopic($connection->app->id, $channelName); + } + }); + }) + ->then(function () use ($connection, $channelName) { + return $this->removeChannelFromSet($connection->app->id, $channelName); + }) + ->then(function () use ($connection) { + return $this->removeConnectionFromSet($connection); + }) + ->then(function () use ($connection, $channelName, $payload) { + return parent::unsubscribeFromChannel($connection, $channelName, $payload); + }); + } + + /** + * Subscribe the connection to a specific channel, returning + * a promise containing the amount of connections. + * + * @param string|int $appId + * @return PromiseInterface[int] + */ + public function subscribeToApp($appId): PromiseInterface + { + return $this->subscribeToTopic($appId) + ->then(function () use ($appId) { + return $this->incrementSubscriptionsCount($appId); + }); + } + + /** + * Unsubscribe the connection from the channel, returning + * a promise containing the amount of connections after decrement. + * + * @param string|int $appId + * @return PromiseInterface[int] + */ + public function unsubscribeFromApp($appId): PromiseInterface + { + return $this->unsubscribeFromTopic($appId) + ->then(function () use ($appId) { + return $this->decrementSubscriptionsCount($appId); + }); + } + + /** + * Get the connections count on the app + * for the current server instance. + * + * @param string|int $appId + * @param string|null $channelName + * @return PromiseInterface[int] + */ + 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 PromiseInterface[int] + */ + 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|null $socketId + * @param string $channel + * @param stdClass $payload + * @param string|null $serverId + * @return PromiseInterface[bool] + */ + public function broadcastAcrossServers($appId, ?string $socketId, string $channel, stdClass $payload, string $serverId = null): PromiseInterface + { + $payload->appId = $appId; + $payload->socketId = $socketId; + $payload->serverId = $serverId ?: $this->getServerId(); + + return $this->publishClient + ->publish($this->getRedisKey($appId, $channel), json_encode($payload)) + ->then(function () use ($appId, $socketId, $channel, $payload, $serverId) { + return parent::broadcastAcrossServers($appId, $socketId, $channel, $payload, $serverId); + }); + } + + /** + * Handle the user when it joined a presence channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param stdClass $user + * @param string $channel + * @param stdClass $payload + * @return PromiseInterface + */ + public function userJoinedPresenceChannel(ConnectionInterface $connection, stdClass $user, string $channel, stdClass $payload): PromiseInterface + { + return $this->storeUserData($connection->app->id, $channel, $connection->socketId, json_encode($user)) + ->then(function () use ($connection, $channel, $user) { + return $this->addUserSocket($connection->app->id, $channel, $user, $connection->socketId); + }) + ->then(function () use ($connection, $user, $channel, $payload) { + return parent::userJoinedPresenceChannel($connection, $user, $channel, $payload); + }); + } + + /** + * Handle the user when it left a presence channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param stdClass $user + * @param string $channel + * @param stdClass $payload + * @return PromiseInterface[bool] + */ + public function userLeftPresenceChannel(ConnectionInterface $connection, stdClass $user, string $channel): PromiseInterface + { + return $this->removeUserData($connection->app->id, $channel, $connection->socketId) + ->then(function () use ($connection, $channel, $user) { + return $this->removeUserSocket($connection->app->id, $channel, $user, $connection->socketId); + }) + ->then(function () use ($connection, $user, $channel) { + return parent::userLeftPresenceChannel($connection, $user, $channel); + }); + } + + /** + * Get the presence channel members. + * + * @param string|int $appId + * @param string $channel + * @return \React\Promise\PromiseInterface[array] + */ + public function getChannelMembers($appId, string $channel): PromiseInterface + { + return $this->publishClient + ->hgetall($this->getRedisKey($appId, $channel, ['users'])) + ->then(function ($list) { + return collect(Helpers::redisListToArray($list))->map(function ($user) { + return json_decode($user); + })->unique('user_id')->toArray(); + }); + } + + /** + * Get a member from a presence channel based on connection. + * + * @param \Ratchet\ConnectionInterface $connection + * @param string $channel + * @return \React\Promise\PromiseInterface[null|array] + */ + 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[array] + */ + 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); + }); + } + + /** + * Get the socket IDs for a presence channel member. + * + * @param string|int $userId + * @param string|int $appId + * @param string $channelName + * @return \React\Promise\PromiseInterface[array] + */ + public function getMemberSockets($userId, $appId, $channelName): PromiseInterface + { + return $this->publishClient->smembers( + $this->getRedisKey($appId, $channelName, [$userId, 'userSockets']) + ); + } + + /** + * Keep tracking the connections availability when they pong. + * + * @param \Ratchet\ConnectionInterface $connection + * @return PromiseInterface[bool] + */ + public function connectionPonged(ConnectionInterface $connection): PromiseInterface + { + // This will update the score with the current timestamp. + return $this->addConnectionToSet($connection, Carbon::now()) + ->then(function () use ($connection) { + return parent::connectionPonged($connection); + }); + } + + /** + * Remove the obsolete connections that didn't ponged in a while. + * + * @return PromiseInterface[bool] + */ + public function removeObsoleteConnections(): PromiseInterface + { + $this->lock()->get(function () { + $this->getConnectionsFromSet(0, now()->subMinutes(2)->format('U')) + ->then(function ($connections) { + foreach ($connections as $socketId => $appId) { + $connection = $this->fakeConnectionForApp($appId, $socketId); + + $this->unsubscribeFromAllChannels($connection); + } + }); + }); + + return parent::removeObsoleteConnections(); + } + + /** + * 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->broadcastLocallyToEveryoneExcept($payload, $socketId, $appId); + } + + /** + * 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 Redis client used by other classes. + * + * @return Client + */ + public function getRedisClient() + { + return $this->getPublishClient(); + } + + /** + * Get the unique identifier for the server. + * + * @return string + */ + public function getServerId(): string + { + return $this->serverId; + } + + /** + * Increment the subscribed count number. + * + * @param string|int $appId + * @param string|null $channel + * @param int $increment + * @return PromiseInterface[int] + */ + public function incrementSubscriptionsCount($appId, string $channel = null, int $increment = 1): PromiseInterface + { + return $this->publishClient->hincrby( + $this->getRedisKey($appId, $channel, ['stats']), 'connections', $increment + ); + } + + /** + * Decrement the subscribed count number. + * + * @param string|int $appId + * @param string|null $channel + * @param int $decrement + * @return PromiseInterface[int] + */ + public function decrementSubscriptionsCount($appId, string $channel = null, int $increment = 1): PromiseInterface + { + return $this->incrementSubscriptionsCount($appId, $channel, $increment * -1); + } + + /** + * Add the connection to the sorted list. + * + * @param \Ratchet\ConnectionInterface $connection + * @param \DateTime|string|null $moment + * @return PromiseInterface + */ + public function addConnectionToSet(ConnectionInterface $connection, $moment = null): PromiseInterface + { + $moment = $moment ? Carbon::parse($moment) : Carbon::now(); + + return $this->publishClient->zadd( + $this->getRedisKey(null, null, ['sockets']), + $moment->format('U'), "{$connection->app->id}:{$connection->socketId}" + ); + } + + /** + * Remove the connection from the sorted list. + * + * @param \Ratchet\ConnectionInterface $connection + * @return PromiseInterface + */ + public function removeConnectionFromSet(ConnectionInterface $connection): PromiseInterface + { + return $this->publishClient->zrem( + $this->getRedisKey(null, null, ['sockets']), + "{$connection->app->id}:{$connection->socketId}" + ); + } + + /** + * Get the connections from the sorted list, with last + * connection between certain timestamps. + * + * @param int $start + * @param int $stop + * @param bool $strict + * @return PromiseInterface[array] + */ + public function getConnectionsFromSet(int $start = 0, int $stop = 0, bool $strict = true): PromiseInterface + { + if ($strict) { + $start = "({$start}"; + $stop = "({$stop}"; + } + + return $this->publishClient + ->zrangebyscore($this->getRedisKey(null, null, ['sockets']), $start, $stop) + ->then(function ($list) { + return collect($list)->mapWithKeys(function ($appWithSocket) { + [$appId, $socketId] = explode(':', $appWithSocket); + + return [$socketId => $appId]; + })->toArray(); + }); + } + + /** + * Add a channel to the set list. + * + * @param string|int $appId + * @param string $channel + * @return PromiseInterface + */ + public function addChannelToSet($appId, string $channel): PromiseInterface + { + return $this->publishClient->sadd( + $this->getRedisKey($appId, null, ['channels']), $channel + ); + } + + /** + * Remove a channel from the set list. + * + * @param string|int $appId + * @param string $channel + * @return PromiseInterface + */ + public function removeChannelFromSet($appId, string $channel): PromiseInterface + { + return $this->publishClient->srem( + $this->getRedisKey($appId, null, ['channels']), $channel + ); + } + + /** + * Set data for a topic. Might be used for the presence channels. + * + * @param string|int $appId + * @param string|null $channel + * @param string $key + * @param string $data + * @return PromiseInterface + */ + public function storeUserData($appId, string $channel = null, string $key, $data): PromiseInterface + { + return $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): PromiseInterface + { + return $this->publishClient->hdel( + $this->getRedisKey($appId, $channel, ['users']), $key + ); + } + + /** + * Subscribe to the topic for the app, or app and channel. + * + * @param string|int $appId + * @param string|null $channel + * @return PromiseInterface + */ + public function subscribeToTopic($appId, string $channel = null): PromiseInterface + { + return $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 PromiseInterface + */ + public function unsubscribeFromTopic($appId, string $channel = null): PromiseInterface + { + return $this->subscribeClient->unsubscribe( + $this->getRedisKey($appId, $channel) + ); + } + + /** + * Add the Presence Channel's User's Socket ID to a list. + * + * @param string|int $appId + * @param string $channel + * @param stdClass $user + * @param string $socketId + * @return PromiseInterface + */ + protected function addUserSocket($appId, string $channel, stdClass $user, string $socketId): PromiseInterface + { + return $this->publishClient->sadd( + $this->getRedisKey($appId, $channel, [$user->user_id, 'userSockets']), $socketId + ); + } + + /** + * Remove the Presence Channel's User's Socket ID from the list. + * + * @param string|int $appId + * @param string $channel + * @param stdClass $user + * @param string $socketId + * @return PromiseInterface + */ + protected function removeUserSocket($appId, string $channel, stdClass $user, string $socketId): PromiseInterface + { + return $this->publishClient->srem( + $this->getRedisKey($appId, $channel, [$user->user_id, 'userSockets']), $socketId + ); + } + + /** + * Get the Redis Keyspace name to handle subscriptions + * and other key-value sets. + * + * @param string|int|null $appId + * @param string|null $channel + * @return string + */ + public function getRedisKey($appId = null, string $channel = null, array $suffixes = []): string + { + $prefix = config('database.redis.options.prefix', null); + + $hash = "{$prefix}{$appId}"; + + if ($channel) { + $suffixes = array_merge([$channel], $suffixes); + } + + $suffixes = implode(':', $suffixes); + + if ($suffixes) { + $hash .= ":{$suffixes}"; + } + + return $hash; + } + + /** + * Get a new RedisLock instance to avoid race conditions. + * + * @return \Illuminate\Cache\CacheLock + */ + protected function lock() + { + return new RedisLock($this->redis, static::$lockName, 0); + } + + /** + * Create a fake connection for app that will mimick a connection + * by app ID and Socket ID to be able to be passed to the methods + * that accepts a connection class. + * + * @param string|int $appId + * @param string $socketId + * @return ConnectionInterface + */ + public function fakeConnectionForApp($appId, string $socketId) + { + return new MockableConnection($appId, $socketId); + } +} diff --git a/src/Channels/Channel.php b/src/Channels/Channel.php new file mode 100644 index 0000000..fd857e2 --- /dev/null +++ b/src/Channels/Channel.php @@ -0,0 +1,246 @@ +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 bool + */ + public function subscribe(ConnectionInterface $connection, stdClass $payload): bool + { + $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(), + ]); + + SubscribedToChannel::dispatch( + $connection->app->id, + $connection->socketId, + $this->getName(), + ); + + return true; + } + + /** + * Unsubscribe connection from the channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @return bool + */ + public function unsubscribe(ConnectionInterface $connection): bool + { + if (! $this->hasConnection($connection)) { + return false; + } + + unset($this->connections[$connection->socketId]); + + UnsubscribedFromChannel::dispatch( + $connection->app->id, + $connection->socketId, + $this->getName() + ); + + return true; + } + + /** + * Check if the given connection exists. + * + * @param \Ratchet\ConnectionInterface $connection + * @return bool + */ + public function hasConnection(ConnectionInterface $connection): bool + { + return isset($this->connections[$connection->socketId]); + } + + /** + * Store the connection to the subscribers list. + * + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ + public 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, null, $this->getName(), $payload); + } + + return true; + } + + /** + * Broadcast a payload to the locally-subscribed connections. + * + * @param string|int $appId + * @param \stdClass $payload + * @return bool + */ + public function broadcastLocally($appId, stdClass $payload): bool + { + return $this->broadcast($appId, $payload, false); + } + + /** + * 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, $socketId, $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; + } + + /** + * Broadcast the payload, but exclude a specific socket id. + * + * @param \stdClass $payload + * @param string|null $socketId + * @param string|int $appId + * @return bool + */ + public function broadcastLocallyToEveryoneExcept(stdClass $payload, ?string $socketId, $appId) + { + return $this->broadcastToEveryoneExcept( + $payload, $socketId, $appId, false + ); + } + + /** + * 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..614fe8d --- /dev/null +++ b/src/Channels/PresenceChannel.php @@ -0,0 +1,154 @@ +verifySignature($connection, $payload); + + $this->saveConnection($connection); + + $user = json_decode($payload->channel_data); + + $this->channelManager + ->userJoinedPresenceChannel($connection, $user, $this->getName(), $payload) + ->then(function () use ($connection) { + $this->channelManager + ->getChannelMembers($connection->app->id, $this->getName()) + ->then(function ($users) use ($connection) { + $hash = []; + + foreach ($users as $socketId => $user) { + $hash[$user->user_id] = $user->user_info ?? []; + } + + $connection->send(json_encode([ + 'event' => 'pusher_internal:subscription_succeeded', + 'channel' => $this->getName(), + 'data' => json_encode([ + 'presence' => [ + 'ids' => collect($users)->map(function ($user) { + return (string) $user->user_id; + })->values(), + 'hash' => $hash, + 'count' => count($users), + ], + ]), + ])); + }); + }) + ->then(function () use ($connection, $user, $payload) { + // The `pusher_internal:member_added` event is triggered when a user joins a channel. + // It's quite possible that a user can have multiple connections to the same channel + // (for example by having multiple browser tabs open) + // and in this case the events will only be triggered when the first tab is opened. + $this->channelManager + ->getMemberSockets($user->user_id, $connection->app->id, $this->getName()) + ->then(function ($sockets) use ($payload, $connection, $user) { + if (count($sockets) === 1) { + $memberAddedPayload = [ + 'event' => 'pusher_internal:member_added', + 'channel' => $this->getName(), + 'data' => $payload->channel_data, + ]; + + $this->broadcastToEveryoneExcept( + (object) $memberAddedPayload, $connection->socketId, + $connection->app->id + ); + + SubscribedToChannel::dispatch( + $connection->app->id, + $connection->socketId, + $this->getName(), + $user + ); + } + + DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_SUBSCRIBED, [ + 'socketId' => $connection->socketId, + 'channel' => $this->getName(), + 'duplicate-connection' => count($sockets) > 1, + ]); + }); + }); + + return true; + } + + /** + * Unsubscribe connection from the channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @return bool + */ + public function unsubscribe(ConnectionInterface $connection): bool + { + $truth = parent::unsubscribe($connection); + + $this->channelManager + ->getChannelMember($connection, $this->getName()) + ->then(function ($user) { + return @json_decode($user); + }) + ->then(function ($user) use ($connection) { + if (! $user) { + return; + } + + $this->channelManager + ->userLeftPresenceChannel($connection, $user, $this->getName()) + ->then(function () use ($connection, $user) { + // The `pusher_internal:member_removed` is triggered when a user leaves a channel. + // It's quite possible that a user can have multiple connections to the same channel + // (for example by having multiple browser tabs open) + // and in this case the events will only be triggered when the last one is closed. + $this->channelManager + ->getMemberSockets($user->user_id, $connection->app->id, $this->getName()) + ->then(function ($sockets) use ($connection, $user) { + if (count($sockets) === 0) { + $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 + ); + + UnsubscribedFromChannel::dispatch( + $connection->app->id, + $connection->socketId, + $this->getName(), + $user + ); + } + }); + }); + }); + + return $truth; + } +} diff --git a/src/WebSockets/Channels/PrivateChannel.php b/src/Channels/PrivateChannel.php similarity index 68% rename from src/WebSockets/Channels/PrivateChannel.php rename to src/Channels/PrivateChannel.php index 5f84308..93914e5 100644 --- a/src/WebSockets/Channels/PrivateChannel.php +++ b/src/Channels/PrivateChannel.php @@ -1,8 +1,8 @@ verifySignature($connection, $payload); - parent::subscribe($connection, $payload); + return parent::subscribe($connection, $payload); } } diff --git a/src/Concerns/PushesToPusher.php b/src/Concerns/PushesToPusher.php new file mode 100644 index 0000000..e50dafd --- /dev/null +++ b/src/Concerns/PushesToPusher.php @@ -0,0 +1,27 @@ +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/Commands/FlushCollectedStatistics.php b/src/Console/Commands/FlushCollectedStatistics.php new file mode 100644 index 0000000..274129f --- /dev/null +++ b/src/Console/Commands/FlushCollectedStatistics.php @@ -0,0 +1,37 @@ +comment('Flushing the collected WebSocket Statistics...'); + + StatisticsCollector::flush(); + + $this->line('Flush complete!'); + } +} 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/Commands/StartServer.php b/src/Console/Commands/StartServer.php new file mode 100644 index 0000000..890a4f1 --- /dev/null +++ b/src/Console/Commands/StartServer.php @@ -0,0 +1,317 @@ +loop = LoopFactory::create(); + } + + /** + * Run the command. + * + * @return void + */ + public function handle() + { + $this->configureLoggers(); + + $this->configureManagers(); + + $this->configureStatistics(); + + $this->configureRestartTimer(); + + $this->configureRoutes(); + + $this->configurePcntlSignal(); + + $this->configurePongTracker(); + + $this->startServer(); + } + + /** + * Configure the loggers used for the console. + * + * @return void + */ + protected function configureLoggers() + { + $this->configureHttpLogger(); + $this->configureMessageLogger(); + $this->configureConnectionLogger(); + } + + /** + * Register the managers that are not resolved + * in the package service provider. + * + * @return void + */ + protected function configureManagers() + { + $this->laravel->singleton(ChannelManager::class, function () { + $mode = config('websockets.replication.mode', 'local'); + + $class = config("websockets.replication.modes.{$mode}.channel_manager"); + + return new $class($this->loop); + }); + } + + /** + * Register the Statistics Collectors that + * are not resolved in the package service provider. + * + * @return void + */ + protected function configureStatistics() + { + 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 restart timer. + * + * @return void + */ + public function configureRestartTimer() + { + $this->lastRestart = $this->getLastRestart(); + + $this->loop->addPeriodicTimer(10, function () { + if ($this->getLastRestart() !== $this->lastRestart) { + $this->triggerSoftShutdown(); + } + }); + } + + /** + * Register the routes for the server. + * + * @return void + */ + protected function configureRoutes() + { + WebSocketRouter::routes(); + } + + /** + * Configure the PCNTL signals for soft shutdown. + * + * @return void + */ + protected function configurePcntlSignal() + { + // When the process receives a SIGTERM or a SIGINT + // signal, it should mark the server as unavailable + // to receive new connections, close the current connections, + // then stopping the loop. + + $this->loop->addSignal(SIGTERM, function () { + $this->line('Closing existing connections...'); + + $this->triggerSoftShutdown(); + }); + + $this->loop->addSignal(SIGINT, function () { + $this->line('Closing existing connections...'); + + $this->triggerSoftShutdown(); + }); + } + + /** + * Configure the tracker that will delete + * from the store the connections that. + * + * @return void + */ + protected function configurePongTracker() + { + $this->loop->addPeriodicTimer(10, function () { + $this->laravel + ->make(ChannelManager::class) + ->removeObsoleteConnections(); + }); + } + + /** + * Configure the HTTP logger class. + * + * @return void + */ + protected function configureHttpLogger() + { + $this->laravel->singleton(HttpLogger::class, function () { + return (new HttpLogger($this->output)) + ->enable($this->option('debug') ?: config('app.debug')) + ->verbose($this->output->isVerbose()); + }); + } + + /** + * Configure the logger for messages. + * + * @return void + */ + protected function configureMessageLogger() + { + $this->laravel->singleton(WebSocketsLogger::class, function () { + return (new WebSocketsLogger($this->output)) + ->enable($this->option('debug') ?: config('app.debug')) + ->verbose($this->output->isVerbose()); + }); + } + + /** + * 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()); + }); + } + + /** + * Start the server. + * + * @return void + */ + protected function startServer() + { + $this->info("Starting the WebSocket server on port {$this->option('port')}..."); + + $this->buildServer(); + + $this->server->run(); + } + + /** + * Build the server instance. + * + * @return void + */ + protected function buildServer() + { + $this->server = new ServerFactory( + $this->option('host'), $this->option('port') + ); + + if ($loop = $this->option('loop')) { + $this->loop = $loop; + } + + $this->server = $this->server + ->setLoop($this->loop) + ->withRoutes(WebSocketRouter::getRoutes()) + ->setConsoleOutput($this->output) + ->createServer(); + } + + /** + * Get the last time the server restarted. + * + * @return int + */ + protected function getLastRestart() + { + return Cache::get( + 'beyondcode:websockets:restart', 0 + ); + } + + /** + * Trigger a soft shutdown for the process. + * + * @return void + */ + protected function triggerSoftShutdown() + { + $channelManager = $this->laravel->make(ChannelManager::class); + + // Close the new connections allowance on this server. + $channelManager->declineNewConnections(); + + // Get all local connections and close them. They will + // be automatically be unsubscribed from all channels. + $channelManager->getLocalConnections() + ->then(function ($connections) { + foreach ($connections as $connection) { + $connection->close(); + } + }) + ->then(function () { + $this->loop->stop(); + }); + } +} diff --git a/src/Console/StartWebSocketServer.php b/src/Console/StartWebSocketServer.php deleted file mode 100644 index d6c4dcb..0000000 --- a/src/Console/StartWebSocketServer.php +++ /dev/null @@ -1,273 +0,0 @@ -loop = LoopFactory::create(); - } - - /** - * Run the command. - * - * @return void - */ - public function handle() - { - $this - ->configureStatisticsLogger() - ->configureHttpLogger() - ->configureMessageLogger() - ->configureConnectionLogger() - ->configureRestartTimer() - ->configurePubSub() - ->registerRoutes() - ->startWebSocketServer(); - } - - /** - * Configure the statistics logger class. - * - * @return $this - */ - protected function configureStatisticsLogger() - { - $this->laravel->singleton(StatisticsLoggerInterface::class, function () { - $class = config('websockets.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; - } - - /** - * Configure the HTTP logger class. - * - * @return $this - */ - protected function configureHttpLogger() - { - $this->laravel->singleton(HttpLogger::class, function () { - return (new HttpLogger($this->output)) - ->enable($this->option('debug') ?: config('app.debug')) - ->verbose($this->output->isVerbose()); - }); - - return $this; - } - - /** - * Configure the logger for messages. - * - * @return $this - */ - protected function configureMessageLogger() - { - $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 $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 - */ - public function configureRestartTimer() - { - $this->lastRestart = $this->getLastRestart(); - - $this->loop->addPeriodicTimer(10, function () { - if ($this->getLastRestart() !== $this->lastRestart) { - $this->loop->stop(); - } - }); - - return $this; - } - - /** - * Configure the replicators. - * - * @return void - */ - public function configurePubSub() - { - if (config('websockets.replication.driver', 'local') === 'local') { - $this->laravel->singleton(ReplicationInterface::class, function () { - return new LocalClient; - }); - } - - if (config('websockets.replication.driver', 'local') === 'redis') { - $this->laravel->singleton(ReplicationInterface::class, function () { - return (new RedisClient)->boot($this->loop); - }); - } - - $this->laravel - ->get(ReplicationInterface::class) - ->boot($this->loop); - - return $this; - } - - /** - * Register the routes. - * - * @return $this - */ - protected function registerRoutes() - { - WebSocketsRouter::routes(); - - return $this; - } - - /** - * Start the server. - * - * @return void - */ - protected function startWebSocketServer() - { - $this->info("Starting the WebSocket server on port {$this->option('port')}..."); - - $this->buildServer(); - - // For testing, just boot up the server, run it - // but exit after the next tick. - if ($this->option('test')) { - $this->loop->futureTick(function () { - $this->loop->stop(); - }); - } - - /* 🛰 Start the server 🛰 */ - $this->server->run(); - } - - /** - * Build the server instance. - * - * @return void - */ - protected function buildServer() - { - $this->server = new WebSocketServerFactory( - $this->option('host'), $this->option('port') - ); - - $this->server = $this->server - ->setLoop($this->loop) - ->useRoutes(WebSocketsRouter::getRoutes()) - ->setConsoleOutput($this->output) - ->createServer(); - } - - /** - * Get the last time the server restarted. - * - * @return int - */ - protected function getLastRestart() - { - return Cache::get('beyondcode:websockets:restart', 0); - } -} diff --git a/src/Apps/AppManager.php b/src/Contracts/AppManager.php similarity index 76% rename from src/Apps/AppManager.php rename to src/Contracts/AppManager.php index 03c0c9e..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 = new PusherBroadcaster(new Pusher( - $app->key, - $app->secret, - $app->id, - [] - )); + $broadcaster = $this->getPusherBroadcaster([ + 'key' => $app->key, + 'secret' => $app->secret, + 'id' =>$app->id, + ]); /* * Since the dashboard itself is already secured by the diff --git a/src/Dashboard/Http/Controllers/DashboardApiController.php b/src/Dashboard/Http/Controllers/DashboardApiController.php deleted file mode 100644 index c240905..0000000 --- a/src/Dashboard/Http/Controllers/DashboardApiController.php +++ /dev/null @@ -1,22 +0,0 @@ -validate([ + $request->validate([ 'appId' => ['required', new AppId], 'key' => 'required|string', 'secret' => 'required|string', - 'channel' => 'required|string', 'event' => 'required|string', + 'channel' => 'required|string', 'data' => 'required|json', ]); - $this->getPusherBroadcaster($validated)->broadcast( - [$validated['channel']], - $validated['event'], - json_decode($validated['data'], true) - ); + $broadcaster = $this->getPusherBroadcaster([ + 'key' => $request->key, + 'secret' => $request->secret, + 'id' => $request->appId, + ]); - return 'ok'; - } + try { + $decodedData = json_decode($request->data, true); - /** - * Get the pusher broadcaster for the current request. - * - * @param array $validated - * @return \Illuminate\Broadcasting\Broadcasters\PusherBroadcaster - */ - protected function getPusherBroadcaster(array $validated): PusherBroadcaster - { - $pusher = new Pusher( - $validated['key'], - $validated['secret'], - $validated['appId'], - config('broadcasting.connections.pusher.options', []) - ); + $broadcaster->broadcast( + [$request->channel], + $request->event, + $decodedData ?: [] + ); + } catch (Exception $e) { + return response()->json([ + 'ok' => false, + 'exception' => $e->getMessage(), + ]); + } - return new PusherBroadcaster($pusher); + return response()->json([ + 'ok' => true, + ]); } } 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 new file mode 100644 index 0000000..cec51c6 --- /dev/null +++ b/src/Dashboard/Http/Controllers/ShowStatistics.php @@ -0,0 +1,33 @@ +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 72% rename from src/Dashboard/DashboardLogger.php rename to src/DashboardLogger.php index 70397ce..3ab4ded 100644 --- a/src/Dashboard/DashboardLogger.php +++ b/src/DashboardLogger.php @@ -1,8 +1,8 @@ find($appId, $channelName); - - optional($channel)->broadcast([ + $payload = [ 'event' => 'log-message', 'channel' => $channelName, 'data' => [ @@ -77,6 +74,22 @@ class DashboardLogger '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->broadcastLocally( + $appId, (object) $payload, true + ); + } + + $channelManager->broadcastAcrossServers( + $appId, null, $channelName, (object) $payload + ); } } diff --git a/src/Events/ConnectionClosed.php b/src/Events/ConnectionClosed.php new file mode 100644 index 0000000..60b810b --- /dev/null +++ b/src/Events/ConnectionClosed.php @@ -0,0 +1,38 @@ +appId = $appId; + $this->socketId = $socketId; + } +} diff --git a/src/Events/ConnectionPonged.php b/src/Events/ConnectionPonged.php new file mode 100644 index 0000000..43440eb --- /dev/null +++ b/src/Events/ConnectionPonged.php @@ -0,0 +1,38 @@ +appId = $appId; + $this->socketId = $socketId; + } +} diff --git a/src/Events/NewConnection.php b/src/Events/NewConnection.php new file mode 100644 index 0000000..5c8a30f --- /dev/null +++ b/src/Events/NewConnection.php @@ -0,0 +1,38 @@ +appId = $appId; + $this->socketId = $socketId; + } +} diff --git a/src/Events/SubscribedToChannel.php b/src/Events/SubscribedToChannel.php new file mode 100644 index 0000000..b3109f7 --- /dev/null +++ b/src/Events/SubscribedToChannel.php @@ -0,0 +1,57 @@ +appId = $appId; + $this->socketId = $socketId; + $this->channelName = $channelName; + $this->user = $user; + } +} diff --git a/src/Events/UnsubscribedFromChannel.php b/src/Events/UnsubscribedFromChannel.php new file mode 100644 index 0000000..6e132e7 --- /dev/null +++ b/src/Events/UnsubscribedFromChannel.php @@ -0,0 +1,57 @@ +appId = $appId; + $this->socketId = $socketId; + $this->channelName = $channelName; + $this->user = $user; + } +} diff --git a/src/Events/WebSocketMessageReceived.php b/src/Events/WebSocketMessageReceived.php new file mode 100644 index 0000000..442ecb7 --- /dev/null +++ b/src/Events/WebSocketMessageReceived.php @@ -0,0 +1,56 @@ +appId = $appId; + $this->socketId = $socketId; + $this->message = $message; + $this->decodedMessage = json_decode($message->getPayload(), true); + } +} diff --git a/src/Exceptions/InvalidApp.php b/src/Exceptions/InvalidApp.php deleted file mode 100644 index 2270ae0..0000000 --- a/src/Exceptions/InvalidApp.php +++ /dev/null @@ -1,48 +0,0 @@ -setSolutionDescription('Make sure that your `config/websockets.php` contains the app key you are trying to use.') - ->setDocumentationLinks([ - 'Configuring WebSocket Apps (official documentation)' => 'https://docs.beyondco.de/laravel-websockets/1.0/basic-usage/pusher.html#configuring-websocket-apps', - ]); - } -} diff --git a/src/Exceptions/InvalidWebSocketController.php b/src/Exceptions/InvalidWebSocketController.php deleted file mode 100644 index f216e50..0000000 --- a/src/Exceptions/InvalidWebSocketController.php +++ /dev/null @@ -1,24 +0,0 @@ - value array. + [$keys, $values] = collect($list)->partition(function ($value, $key) { + return $key % 2 === 0; + }); + + return array_combine($keys->all(), $values->all()); + } + + /** + * Create a new fulfilled promise with a value. + * + * @param mixed $value + * @return \React\Promise\PromiseInterface + */ + public static function createFulfilledPromise($value): PromiseInterface + { + $resolver = config( + 'websockets.promise_resolver', \React\Promise\FulfilledPromise::class + ); + + return new $resolver($value, static::$loop); + } +} diff --git a/src/HttpApi/Controllers/FetchChannelController.php b/src/HttpApi/Controllers/FetchChannelController.php deleted file mode 100644 index a605ccf..0000000 --- a/src/HttpApi/Controllers/FetchChannelController.php +++ /dev/null @@ -1,26 +0,0 @@ -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 960a0db..0000000 --- a/src/HttpApi/Controllers/FetchChannelsController.php +++ /dev/null @@ -1,90 +0,0 @@ -replicator = $replicator; - } - - /** - * Handle the incoming request. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\Response - */ - public function __invoke(Request $request) - { - $attributes = []; - - if ($request->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 96c7487..0000000 --- a/src/HttpApi/Controllers/TriggerEventController.php +++ /dev/null @@ -1,41 +0,0 @@ -ensureValidSignature($request); - - foreach ($request->json()->get('channels', []) as $channelName) { - $channel = $this->channelManager->find($request->appId, $channelName); - - optional($channel)->broadcastToEveryoneExcept([ - 'channel' => $channelName, - 'event' => $request->json()->get('name'), - 'data' => $request->json()->get('data'), - ], $request->json()->get('socket_id'), $request->appId); - - 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 @@ pusher = $pusher; - $this->appId = $appId; - $this->redis = $redis; - $this->connection = $connection; - } - - /** - * Authenticate the incoming request for a given channel. - * - * @param \Illuminate\Http\Request $request - * @return mixed - * - * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException - */ - public function auth($request) - { - $channelName = $this->normalizeChannelName($request->channel_name); - - if ($this->isGuardedChannel($request->channel_name) && - ! $this->retrieveUser($request, $channelName)) { - throw new AccessDeniedHttpException; - } - - return parent::verifyUserCanAccessChannel( - $request, $channelName - ); - } - - /** - * Return the valid authentication response. - * - * @param \Illuminate\Http\Request $request - * @param mixed $result - * @return mixed - * @throws \Pusher\PusherException - */ - public function validAuthenticationResponse($request, $result) - { - if (Str::startsWith($request->channel_name, 'private')) { - return $this->decodePusherResponse( - $request, $this->pusher->socket_auth($request->channel_name, $request->socket_id) - ); - } - - $channelName = $this->normalizeChannelName($request->channel_name); - - return $this->decodePusherResponse( - $request, - $this->pusher->presence_auth( - $request->channel_name, $request->socket_id, - $this->retrieveUser($request, $channelName)->getAuthIdentifier(), $result - ) - ); - } - - /** - * Decode the given Pusher response. - * - * @param \Illuminate\Http\Request $request - * @param mixed $response - * @return array - */ - protected function decodePusherResponse($request, $response) - { - if (! $request->input('callback', false)) { - return json_decode($response, true); - } - - return response()->json(json_decode($response, true)) - ->withCallback($request->callback); - } - - /** - * Broadcast the given event. - * - * @param array $channels - * @param string $event - * @param array $payload - * @return void - */ - public function broadcast(array $channels, $event, array $payload = []) - { - $connection = $this->redis->connection($this->connection); - - $payload = json_encode([ - 'appId' => $this->appId, - 'event' => $event, - 'data' => $payload, - 'socket' => Arr::pull($payload, 'socket'), - ]); - - foreach ($this->formatChannels($channels) as $channel) { - $connection->publish("{$this->appId}:$channel", $payload); - } - } -} diff --git a/src/PubSub/Drivers/LocalClient.php b/src/PubSub/Drivers/LocalClient.php deleted file mode 100644 index fe55715..0000000 --- a/src/PubSub/Drivers/LocalClient.php +++ /dev/null @@ -1,140 +0,0 @@ -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); - } -} diff --git a/src/PubSub/Drivers/RedisClient.php b/src/PubSub/Drivers/RedisClient.php deleted file mode 100644 index 255d826..0000000 --- a/src/PubSub/Drivers/RedisClient.php +++ /dev/null @@ -1,374 +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->__call('publish', ["{$appId}:{$channel}", $payload]); - - DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_MESSAGE_PUBLISHED, [ - 'channel' => $channel, - 'serverId' => $this->getServerId(), - 'payload' => $payload, - 'pubsub' => "{$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->__call('subscribe', ["{$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' => "{$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->__call('unsubscribe', ["{$appId}:{$channel}"]); - - unset($this->subscribedChannels["{$appId}:{$channel}"]); - } - - DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_UNSUBSCRIBED, [ - 'channel' => $channel, - 'serverId' => $this->getServerId(), - 'pubsub' => "{$appId}:{$channel}", - ]); - - 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->__call('hset', ["{$appId}:{$channel}", $socketId, $data]); - - DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_JOINED_CHANNEL, [ - 'channel' => $channel, - 'serverId' => $this->getServerId(), - 'socketId' => $socketId, - 'data' => $data, - 'pubsub' => "{$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->__call('hdel', ["{$appId}:{$channel}", $socketId]); - - DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_LEFT_CHANNEL, [ - 'channel' => $channel, - 'serverId' => $this->getServerId(), - 'socketId' => $socketId, - 'pubsub' => "{$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->__call('hgetall', ["{$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->__call('multi', []); - - foreach ($channelNames as $channel) { - $this->publishClient->__call('hlen', ["{$appId}:{$channel}"]); - } - - return $this->publishClient->__call('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 - */ - protected 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; - } - - $socket = $payload->socket ?? null; - $serverId = $payload->serverId ?? null; - - // Remove fields intended for internal use from the payload. - unset($payload->socket); - unset($payload->serverId); - unset($payload->appId); - - // Push the message out to connected websocket clients. - $channel->broadcastToEveryoneExcept($payload, $socket, $appId, false); - - DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_MESSAGE_RECEIVED, [ - 'channel' => $channel->getChannelName(), - 'redisChannel' => $redisChannel, - 'serverId' => $this->getServerId(), - 'incomingServerId' => $serverId, - 'incomingSocketId' => $socket, - '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; - } -} diff --git a/src/PubSub/ReplicationInterface.php b/src/PubSub/ReplicationInterface.php deleted file mode 100644 index e0b39a8..0000000 --- a/src/PubSub/ReplicationInterface.php +++ /dev/null @@ -1,88 +0,0 @@ -redis, $config['queue'], + $config['connection'] ?? $this->connection, + $config['retry_after'] ?? 60, + $config['block_for'] ?? null + ); + } +} diff --git a/src/Queue/AsyncRedisQueue.php b/src/Queue/AsyncRedisQueue.php new file mode 100644 index 0000000..6f9874d --- /dev/null +++ b/src/Queue/AsyncRedisQueue.php @@ -0,0 +1,25 @@ +container->bound(ChannelManager::class) + ? $this->container->make(ChannelManager::class) + : null; + + return $channelManager && method_exists($channelManager, 'getRedisClient') + ? $channelManager->getRedisClient() + : parent::getConnection(); + } +} diff --git a/src/Statistics/Rules/AppId.php b/src/Rules/AppId.php similarity index 86% rename from src/Statistics/Rules/AppId.php rename to src/Rules/AppId.php index 1642d5c..ce5ea2e 100644 --- a/src/Statistics/Rules/AppId.php +++ b/src/Rules/AppId.php @@ -1,8 +1,8 @@ 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/HealthHandler.php b/src/Server/HealthHandler.php new file mode 100644 index 0000000..73186c4 --- /dev/null +++ b/src/Server/HealthHandler.php @@ -0,0 +1,65 @@ + 'application/json'], + json_encode(['ok' => true]) + ); + + tap($connection)->send(\GuzzleHttp\Psr7\str($response))->close(); + } + + /** + * Handle the incoming message. + * + * @param \Ratchet\ConnectionInterface $connection + * @param string $message + * @return void + */ + public function onMessage(ConnectionInterface $connection, $message) + { + // + } + + /** + * Handle the websocket close. + * + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ + public function onClose(ConnectionInterface $connection) + { + // + } + + /** + * Handle the websocket errors. + * + * @param \Ratchet\ConnectionInterface $connection + * @param WebSocketException $exception + * @return void + */ + public function onError(ConnectionInterface $connection, Exception $exception) + { + // + } +} 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->event, ':')); + + if (method_exists($this, $eventName) && $eventName !== 'respond') { + call_user_func([$this, $eventName], $this->connection, $this->payload->data ?? new stdClass()); + } + } + + /** + * Ping the connection. + * + * @see https://pusher.com/docs/pusher_protocol#ping-pong + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ + protected function ping(ConnectionInterface $connection) + { + $this->channelManager + ->connectionPonged($connection) + ->then(function () use ($connection) { + $connection->send(json_encode(['event' => 'pusher:pong'])); + + ConnectionPonged::dispatch($connection->app->id, $connection->socketId); + }); + } + + /** + * Subscribe to channel. + * + * @see https://pusher.com/docs/pusher_protocol#pusher-subscribe + * @param \Ratchet\ConnectionInterface $connection + * @param \stdClass $payload + * @return void + */ + protected function subscribe(ConnectionInterface $connection, stdClass $payload) + { + $this->channelManager->subscribeToChannel($connection, $payload->channel, $payload); + } + + /** + * Unsubscribe from the channel. + * + * @param \Ratchet\ConnectionInterface $connection + * @param \stdClass $payload + * @return void + */ + public function unsubscribe(ConnectionInterface $connection, stdClass $payload) + { + $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..7b4dc64 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, + 'channel' => $this->payload->channel, '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 @@ app = new stdClass; + + $this->app->id = $appId; + $this->socketId = $socketId; + } + + /** + * Send data to the connection. + * + * @param string $data + * @return \Ratchet\ConnectionInterface + */ + public function send($data) + { + // + } + + /** + * Close the connection. + * + * @return void + */ + public function close() + { + // + } +} diff --git a/src/QueryParameters.php b/src/Server/QueryParameters.php similarity index 95% rename from src/QueryParameters.php rename to src/Server/QueryParameters.php index f0590e7..56d324e 100644 --- a/src/QueryParameters.php +++ b/src/Server/QueryParameters.php @@ -1,6 +1,6 @@ routes = new RouteCollection; - $this->customRoutes = new Collection(); } /** @@ -53,22 +38,18 @@ 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', TriggerEventController::class); - $this->get('/apps/{appId}/channels', FetchChannelsController::class); - $this->get('/apps/{appId}/channels/{channelName}', FetchChannelController::class); - $this->get('/apps/{appId}/channels/{channelName}/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')); + $this->get('/health', config('websockets.handlers.health')); } /** @@ -131,23 +112,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 +135,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 52% rename from src/WebSockets/WebSocketHandler.php rename to src/Server/WebSocketHandler.php index 7a2537e..8bec389 100644 --- a/src/WebSockets/WebSocketHandler.php +++ b/src/Server/WebSocketHandler.php @@ -1,17 +1,14 @@ connectionCanBeMade($connection)) { + return $connection->close(); + } + $this->verifyAppKey($connection) ->verifyOrigin($connection) ->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); + + $this->channelManager->connectionPonged($connection); + + DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_CONNECTED, [ + 'origin' => "{$request->getUri()->getScheme()}://{$request->getUri()->getHost()}", + 'socketId' => $connection->socketId, + ]); + + NewConnection::dispatch($connection->app->id, $connection->socketId); + } } /** @@ -61,11 +80,21 @@ class WebSocketHandler implements MessageComponentInterface */ public function onMessage(ConnectionInterface $connection, MessageInterface $message) { - $message = PusherMessageFactory::createForMessage($message, $connection, $this->channelManager); + if (! isset($connection->app)) { + return; + } - $message->respond(); + Messages\PusherMessageFactory::createForMessage( + $message, $connection, $this->channelManager + )->respond(); - StatisticsLogger::webSocketMessage($connection); + StatisticsCollector::webSocketMessage($connection->app->id); + + WebSocketMessageReceived::dispatch( + $connection->app->id, + $connection->socketId, + $message + ); } /** @@ -76,13 +105,21 @@ class WebSocketHandler implements MessageComponentInterface */ public function onClose(ConnectionInterface $connection) { - $this->channelManager->removeFromAllChannels($connection); + $this->channelManager + ->unsubscribeFromAllChannels($connection) + ->then(function (bool $unsubscribed) use ($connection) { + if (isset($connection->app)) { + StatisticsCollector::disconnection($connection->app->id); - DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_DISCONNECTED, [ - 'socketId' => $connection->socketId, - ]); + $this->channelManager->unsubscribeFromApp($connection->app->id); - StatisticsLogger::disconnection($connection); + DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_DISCONNECTED, [ + 'socketId' => $connection->socketId, + ]); + + ConnectionClosed::dispatch($connection->app->id, $connection->socketId); + } + }); } /** @@ -94,13 +131,25 @@ 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() )); } } + /** + * Check if the connection can be made for the + * current server instance. + * + * @param \Ratchet\ConnectionInterface $connection + * @return bool + */ + protected function connectionCanBeMade(ConnectionInterface $connection): bool + { + return $this->channelManager->acceptsNewConnections(); + } + /** * Verify the app key validity. * @@ -109,10 +158,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; @@ -137,7 +188,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; @@ -152,10 +203,17 @@ class WebSocketHandler implements MessageComponentInterface protected function limitConcurrentConnections(ConnectionInterface $connection) { if (! is_null($capacity = $connection->app->capacity)) { - $connectionsCount = $this->channelManager->getConnectionCount($connection->app->id); - if ($connectionsCount >= $capacity) { - throw new ConnectionsOverCapacity(); - } + $this->channelManager + ->getGlobalConnectionsCount($connection->app->id) + ->then(function ($connectionsCount) use ($capacity, $connection) { + if ($connectionsCount >= $capacity) { + $exception = new Exceptions\ConnectionsOverCapacity; + + $payload = json_encode($exception->getPayload()); + + tap($connection)->send($payload)->close(); + } + }); } return $this; @@ -192,16 +250,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); - return $this; } } 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..2bb2630 --- /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 Helpers::createFulfilledPromise($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 Helpers::createFulfilledPromise( + $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] = Statistic::new($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..c37b940 --- /dev/null +++ b/src/Statistics/Collectors/RedisCollector.php @@ -0,0 +1,368 @@ +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->arrayToStatisticInstance( + $appId, Helpers::redisListToArray($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) { + $appsWithStatistics = []; + + foreach ($members as $appId) { + $this->channelManager + ->getPublishClient() + ->hgetall($this->channelManager->getRedisKey($appId, null, ['stats'])) + ->then(function ($list) use ($appId, &$appsWithStatistics) { + $appsWithStatistics[$appId] = $this->arrayToStatisticInstance( + $appId, Helpers::redisListToArray($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) { + return $this->arrayToStatisticInstance( + $appId, Helpers::redisListToArray($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 a key-value pair to a Statistic instance. + * + * @param string|int $appId + * @param array $stats + * @return \BeyondCode\LaravelWebSockets\Statistics\Statistic + */ + protected function arrayToStatisticInstance($appId, array $stats) + { + return Statistic::new($appId) + ->setCurrentConnectionsCount($stats['current_connections_count'] ?? 0) + ->setPeakConnectionsCount($stats['peak_connections_count'] ?? 0) + ->setWebSocketMessagesCount($stats['websocket_messages_count'] ?? 0) + ->setApiMessagesCount($stats['api_messages_count'] ?? 0); + } +} diff --git a/src/Statistics/DnsResolver.php b/src/Statistics/DnsResolver.php deleted file mode 100644 index 57cfdcb..0000000 --- a/src/Statistics/DnsResolver.php +++ /dev/null @@ -1,59 +0,0 @@ -resolveInternal($domain); - } - - /** - * Resolve all domains. - * - * @param string $domain - * @param string $type - * @return FulfilledPromise - */ - public function resolveAll($domain, $type) - { - return $this->resolveInternal($domain, $type); - } - - /** - * Resolve the internal domain. - * - * @param string $domain - * @param string $type - * @return FulfilledPromise - */ - private function resolveInternal($domain, $type = null) - { - return new FulfilledPromise($this->internalIp); - } - - /** - * {@inheritdoc} - */ - public function __toString() - { - return $this->internalIp; - } -} diff --git a/src/Statistics/Drivers/DatabaseDriver.php b/src/Statistics/Drivers/DatabaseDriver.php deleted file mode 100644 index cb5e353..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 $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 9b9cfb0..0000000 --- a/src/Statistics/Drivers/StatisticsDriver.php +++ /dev/null @@ -1,78 +0,0 @@ -channelManager = $channelManager; - $this->driver = $driver; - } - - /** - * Handle the incoming websocket message. - * - * @param \Ratchet\ConnectionInterface $connection - * @return void - */ - public function webSocketMessage(ConnectionInterface $connection) - { - $this->findOrMakeStatisticForAppId($connection->app->id) - ->webSocketMessage(); - } - - /** - * Handle the incoming API message. - * - * @param mixed $appId - * @return void - */ - public function apiMessage($appId) - { - $this->findOrMakeStatisticForAppId($appId) - ->apiMessage(); - } - - /** - * Handle the new conection. - * - * @param \Ratchet\ConnectionInterface $connection - * @return void - */ - public function connection(ConnectionInterface $connection) - { - $this->findOrMakeStatisticForAppId($connection->app->id) - ->connection(); - } - - /** - * Handle disconnections. - * - * @param \Ratchet\ConnectionInterface $connection - * @return void - */ - public function disconnection(ConnectionInterface $connection) - { - $this->findOrMakeStatisticForAppId($connection->app->id) - ->disconnection(); - } - - /** - * Save all the stored statistics. - * - * @return void - */ - public function save() - { - foreach ($this->statistics as $appId => $statistic) { - if (! $statistic->isEnabled()) { - continue; - } - - $this->driver::create($statistic->toArray()); - - $currentConnectionCount = $this->channelManager->getConnectionCount($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]; - } -} diff --git a/src/Statistics/Logger/NullStatisticsLogger.php b/src/Statistics/Logger/NullStatisticsLogger.php deleted file mode 100644 index 94e3547..0000000 --- a/src/Statistics/Logger/NullStatisticsLogger.php +++ /dev/null @@ -1,91 +0,0 @@ -channelManager = $channelManager; - $this->driver = $driver; - } - - /** - * Handle the incoming websocket message. - * - * @param \Ratchet\ConnectionInterface $connection - * @return void - */ - public function webSocketMessage(ConnectionInterface $connection) - { - // - } - - /** - * Handle the incoming API message. - * - * @param mixed $appId - * @return void - */ - public function apiMessage($appId) - { - // - } - - /** - * Handle the new conection. - * - * @param \Ratchet\ConnectionInterface $connection - * @return void - */ - public function connection(ConnectionInterface $connection) - { - // - } - - /** - * Handle disconnections. - * - * @param \Ratchet\ConnectionInterface $connection - * @return void - */ - public function disconnection(ConnectionInterface $connection) - { - // - } - - /** - * Save all the stored statistics. - * - * @return void - */ - public function save() - { - // - } -} diff --git a/src/Statistics/Logger/StatisticsLogger.php b/src/Statistics/Logger/StatisticsLogger.php deleted file mode 100644 index 84b09db..0000000 --- a/src/Statistics/Logger/StatisticsLogger.php +++ /dev/null @@ -1,47 +0,0 @@ -appId = $appId; } + /** + * Create a new statistic instance. + * + * @param string|int $appId + * @return \BeyondCode\LaravelWebSockets\Statistics\Statistic + */ + public static function new($appId) + { + return new static($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 +132,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 +144,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 +156,7 @@ class Statistic */ public function webSocketMessage() { - $this->webSocketMessageCount++; + $this->webSocketMessagesCount++; } /** @@ -103,21 +166,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 +192,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..042e72b --- /dev/null +++ b/src/Statistics/Stores/DatabaseStore.php @@ -0,0 +1,140 @@ +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 $this->statisticToArray($statistic); + }) + ->toArray(); + } + + /** + * Get the results for a specific query into a + * format that is easily to read for graphs. + * + * @param callable $processQuery + * @param callable $processCollection + * @return array + */ + public function getForGraph(callable $processQuery = null, callable $processCollection = null): array + { + $statistics = collect( + $this->getRecords($processQuery, $processCollection) + ); + + return $this->statisticsToGraph($statistics); + } + + /** + * Turn the statistic model to an array. + * + * @param \Illuminate\Database\Eloquent\Model $statistic + * @return array + */ + protected function statisticToArray(Model $statistic): array + { + 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, + ]; + } + + /** + * Turn the statistics collection to an array used for graph. + * + * @param \Illuminate\Support\Collection $statistics + * @return array + */ + protected function statisticsToGraph(Collection $statistics): array + { + 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 a08ef36..0000000 --- a/src/WebSockets/Channels/Channel.php +++ /dev/null @@ -1,240 +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); - } - - /** - * 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, - ]); - } - } - - /** - * 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)); - } - } - - /** - * Broadcast the payload, but exclude the current connection. - * - * @param \Ratchet\ConnectionInterface $connection - * @param \stdClass $payload - * @return void - */ - public function broadcastToOthers(ConnectionInterface $connection, $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($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; - } - - foreach ($this->subscribedConnections as $connection) { - if ($connection->socketId !== $socketId) { - $connection->send(json_encode($payload)); - } - } - } - - /** - * 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 fb1721a..0000000 --- a/src/WebSockets/Channels/ChannelManager.php +++ /dev/null @@ -1,50 +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 - */ - public function getConnectionCount($appId): int - { - return collect($this->getChannels($appId)) - ->flatMap(function (Channel $channel) { - return collect($channel->getSubscribedConnections())->pluck('socketId'); - }) - ->unique() - ->count(); - } - - /** - * 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/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/WebSockets/Messages/PusherChannelProtocolMessage.php b/src/WebSockets/Messages/PusherChannelProtocolMessage.php deleted file mode 100644 index 5de2604..0000000 --- a/src/WebSockets/Messages/PusherChannelProtocolMessage.php +++ /dev/null @@ -1,105 +0,0 @@ -payload = $payload; - - $this->connection = $connection; - - $this->channelManager = $channelManager; - } - - /** - * Respond with the payload. - * - * @return void - */ - public function respond() - { - $eventName = Str::camel(Str::after($this->payload->event, ':')); - - if (method_exists($this, $eventName) && $eventName !== 'respond') { - call_user_func([$this, $eventName], $this->connection, $this->payload->data ?? new stdClass()); - } - } - - /** - * Ping the connection. - * - * @see https://pusher.com/docs/pusher_protocol#ping-pong - * @param \Ratchet\ConnectionInterface $connection - * @return void - */ - protected function ping(ConnectionInterface $connection) - { - $connection->send(json_encode([ - 'event' => 'pusher:pong', - ])); - } - - /** - * Subscribe to channel. - * - * @see https://pusher.com/docs/pusher_protocol#pusher-subscribe - * @param \Ratchet\ConnectionInterface $connection - * @param \stdClass $payload - * @return void - */ - protected function subscribe(ConnectionInterface $connection, stdClass $payload) - { - $channel = $this->channelManager->findOrCreate($connection->app->id, $payload->channel); - - $channel->subscribe($connection, $payload); - } - - /** - * Unsubscribe from the channel. - * - * @param \Ratchet\ConnectionInterface $connection - * @param \stdClass $payload - * @return void - */ - public function unsubscribe(ConnectionInterface $connection, stdClass $payload) - { - $channel = $this->channelManager->findOrCreate($connection->app->id, $payload->channel); - - $channel->unsubscribe($connection); - } -} diff --git a/src/WebSocketsServiceProvider.php b/src/WebSocketsServiceProvider.php index 09db778..f513caa 100644 --- a/src/WebSocketsServiceProvider.php +++ b/src/WebSocketsServiceProvider.php @@ -2,23 +2,18 @@ namespace BeyondCode\LaravelWebSockets; -use BeyondCode\LaravelWebSockets\Apps\AppManager; +use BeyondCode\LaravelWebSockets\Contracts\StatisticsCollector; +use BeyondCode\LaravelWebSockets\Contracts\StatisticsStore; use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\AuthenticateDashboard; -use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\DashboardApiController; 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\PubSub\Broadcasters\RedisPusherBroadcaster; 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\Broadcasting\BroadcastManager; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; -use Psr\Log\LoggerInterface; -use Pusher\Pusher; class WebSocketsServiceProvider extends ServiceProvider { @@ -30,25 +25,29 @@ 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'), + __DIR__.'/../database/migrations/0000_00_00_000000_rename_statistics_counters.php' => database_path('migrations/0000_00_00_000000_rename_statistics_counters.php'), ], 'migrations'); - $this->registerDashboardRoutes() - ->registerDashboardGate(); + $this->registerAsyncRedisQueueDriver(); - $this->loadViewsFrom(__DIR__.'/../resources/views/', 'websockets'); + $this->registerRouter(); - $this->commands([ - Console\StartWebSocketServer::class, - Console\CleanStatistics::class, - Console\RestartWebSocketServer::class, - ]); + $this->registerManagers(); - $this->configurePubSub(); + $this->registerStatistics(); + + $this->registerDashboard(); + + $this->registerCommands(); } /** @@ -58,56 +57,92 @@ class WebSocketsServiceProvider extends ServiceProvider */ public function register() { - $this->mergeConfigFrom(__DIR__.'/../config/websockets.php', 'websockets'); + // + } - $this->app->singleton('websockets.router', function () { - return new Router(); - }); - - $this->app->singleton(ChannelManager::class, function () { - $channelManager = config('websockets.managers.channel', ArrayChannelManager::class); - - return new $channelManager; - }); - - $this->app->singleton(AppManager::class, function () { - return $this->app->make(config('websockets.managers.app')); - }); - - $this->app->singleton(StatisticsDriver::class, function () { - $driver = config('websockets.statistics.driver'); - - return $this->app->make( - config('websockets.statistics')[$driver]['driver'] - ?? - \BeyondCode\LaravelWebSockets\Statistics\Drivers\DatabaseDriver::class - ); + /** + * Register the async, non-blocking Redis queue driver. + * + * @return void + */ + protected function registerAsyncRedisQueueDriver() + { + Queue::extend('async-redis', function () { + return new Queue\AsyncRedisConnector($this->app['redis']); }); } /** - * Configure the PubSub replication. + * Register the statistics-related contracts. * * @return void */ - protected function configurePubSub() + protected function registerStatistics() { - $this->app->make(BroadcastManager::class)->extend('websockets', function ($app, array $config) { - $pusher = new Pusher( - $config['key'], $config['secret'], - $config['app_id'], $config['options'] ?? [] - ); + $this->app->singleton(StatisticsStore::class, function () { + $class = config('websockets.statistics.store'); - if ($config['log'] ?? false) { - $pusher->setLogger($this->app->make(LoggerInterface::class)); - } + return new $class; + }); - return new RedisPusherBroadcaster( - $pusher, - $config['app_id'], - $this->app->make('redis'), - $config['connection'] ?? null - ); + $this->app->singleton(StatisticsCollector::class, function () { + $replicationMode = config('websockets.replication.mode', 'local'); + + $class = config("websockets.replication.modes.{$replicationMode}.collector"); + + return new $class; + }); + } + + /** + * 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, + Console\Commands\FlushCollectedStatistics::class, + ]); + } + + /** + * Register the routing. + * + * @return void + */ + protected function registerRouter() + { + $this->app->singleton('websockets.router', function () { + return new Router; + }); + } + + /** + * 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')); }); } @@ -118,16 +153,16 @@ class WebSocketsServiceProvider extends ServiceProvider */ protected function registerDashboardRoutes() { - Route::prefix(config('websockets.dashboard.path'))->group(function () { - Route::middleware(config('websockets.dashboard.middleware', [AuthorizeDashboard::class]))->group(function () { - Route::get('/', ShowDashboard::class); - Route::get('/api/{appId}/statistics', [DashboardApiController::class, 'getStatistics']); - Route::post('auth', AuthenticateDashboard::class); - Route::post('event', SendMessage::class); - }); + Route::group([ + 'prefix' => config('websockets.dashboard.path'), + 'as' => 'laravel-websockets.', + 'middleware' => config('websockets.dashboard.middleware', [AuthorizeDashboard::class]), + ], function () { + Route::get('/', ShowDashboard::class)->name('dashboard'); + Route::get('/api/{appId}/statistics', ShowStatistics::class)->name('statistics'); + Route::post('/auth', AuthenticateDashboard::class)->name('auth'); + Route::post('/event', SendMessage::class)->name('event'); }); - - return $this; } /** @@ -140,7 +175,5 @@ class WebSocketsServiceProvider extends ServiceProvider Gate::define('viewWebSocketsDashboard', function ($user = null) { return $this->app->environment('local'); }); - - return $this; } } diff --git a/tests/AsyncRedisQueueTest.php b/tests/AsyncRedisQueueTest.php new file mode 100644 index 0000000..89db9cd --- /dev/null +++ b/tests/AsyncRedisQueueTest.php @@ -0,0 +1,213 @@ +runOnlyOnRedisReplication(); + + $connector = new AsyncRedisConnector($this->app['redis'], 'default'); + + $this->queue = $connector->connect([ + 'queue' => 'default', + 'retry_after' => 60, + 'block_for' => null, + ]); + + $this->queue->setContainer($this->app); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void + { + parent::tearDown(); + + m::close(); + } + + public function test_expired_jobs_are_pushed_with_async_and_popped_with_sync() + { + $jobs = [ + new RedisQueueIntegrationTestJob(0), + new RedisQueueIntegrationTestJob(1), + new RedisQueueIntegrationTestJob(2), + new RedisQueueIntegrationTestJob(3), + ]; + + $this->queue->later(1000, $jobs[0]); + $this->queue->later(-200, $jobs[1]); + $this->queue->later(-300, $jobs[2]); + $this->queue->later(-100, $jobs[3]); + + $this->getPublishClient() + ->zcard('queues:default:delayed') + ->then(function ($count) { + $this->assertEquals(4, $count); + }); + + $this->unregisterManagers(); + + $this->assertEquals($jobs[2], unserialize(json_decode($this->queue->pop()->getRawBody())->data->command)); + $this->assertEquals($jobs[1], unserialize(json_decode($this->queue->pop()->getRawBody())->data->command)); + $this->assertEquals($jobs[3], unserialize(json_decode($this->queue->pop()->getRawBody())->data->command)); + $this->assertNull($this->queue->pop()); + + $this->assertEquals(1, $this->app['redis']->connection()->zcard('queues:default:delayed')); + $this->assertEquals(3, $this->app['redis']->connection()->zcard('queues:default:reserved')); + } + + public function test_jobs_are_pushed_with_async_and_released_with_sync() + { + $this->queue->push( + $job = new RedisQueueIntegrationTestJob(30) + ); + + $this->unregisterManagers(); + + $this->getPublishClient() + ->assertCalledCount(1, 'eval'); + + $redisJob = $this->queue->pop(); + + $before = $this->currentTime(); + + $redisJob->release(1000); + + $after = $this->currentTime(); + + // check the content of delayed queue + $this->assertEquals(1, $this->app['redis']->connection()->zcard('queues:default:delayed')); + + $results = $this->app['redis']->connection()->zrangebyscore('queues:default:delayed', -INF, INF, ['withscores' => true]); + + $payload = array_keys($results)[0]; + + $score = $results[$payload]; + + $this->assertGreaterThanOrEqual($before + 1000, $score); + $this->assertLessThanOrEqual($after + 1000, $score); + + $decoded = json_decode($payload); + + $this->assertEquals(1, $decoded->attempts); + $this->assertEquals($job, unserialize($decoded->data->command)); + + $this->assertNull($this->queue->pop()); + } + + public function test_jobs_are_pushed_with_async_and_deleted_with_sync() + { + $this->queue->push( + $job = new RedisQueueIntegrationTestJob(30) + ); + + $this->unregisterManagers(); + + $this->getPublishClient() + ->assertCalledCount(1, 'eval'); + + $redisJob = $this->queue->pop(); + + $redisJob->delete(); + + $this->assertEquals(0, $this->app['redis']->connection()->zcard('queues:default:delayed')); + $this->assertEquals(0, $this->app['redis']->connection()->zcard('queues:default:reserved')); + $this->assertEquals(0, $this->app['redis']->connection()->llen('queues:default')); + + $this->assertNull($this->queue->pop()); + } + + public function test_jobs_are_pushed_with_async_and_cleared_with_sync() + { + if (! method_exists($this->queue, 'clear')) { + $this->markTestSkipped('The Queue has no clear() method to test.'); + } + + $job1 = new RedisQueueIntegrationTestJob(30); + $job2 = new RedisQueueIntegrationTestJob(40); + + $this->queue->push($job1); + $this->queue->push($job2); + + $this->getPublishClient() + ->assertCalledCount(2, 'eval'); + + $this->unregisterManagers(); + + $this->assertEquals(2, $this->queue->clear(null)); + $this->assertEquals(0, $this->queue->size()); + } + + public function test_jobs_are_pushed_with_async_and_size_reflects_in_async_size() + { + $this->queue->size()->then(function ($count) { + $this->assertEquals(0, $count); + }); + + $this->queue->push(new RedisQueueIntegrationTestJob(1)); + + $this->queue->size()->then(function ($count) { + $this->assertEquals(1, $count); + }); + + $this->queue->later(60, new RedisQueueIntegrationTestJob(2)); + + $this->queue->size()->then(function ($count) { + $this->assertEquals(2, $count); + }); + + $this->queue->push(new RedisQueueIntegrationTestJob(3)); + + $this->queue->size()->then(function ($count) { + $this->assertEquals(3, $count); + }); + + $this->unregisterManagers(); + + $job = $this->queue->pop(); + + $this->registerManagers(); + + $this->queue->size()->then(function ($count) { + $this->assertEquals(3, $count); + }); + } +} + +class RedisQueueIntegrationTestJob +{ + public $i; + + public function __construct($i) + { + $this->i = $i; + } + + public function handle() + { + // + } +} diff --git a/tests/Channels/ChannelReplicationTest.php b/tests/Channels/ChannelReplicationTest.php deleted file mode 100644 index 4480442..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(json_encode([ - '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(json_encode([ - '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(json_encode([ - '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 a16a83d..0000000 --- a/tests/Channels/ChannelTest.php +++ /dev/null @@ -1,148 +0,0 @@ -getWebSocketConnection(); - - $message = new Message(json_encode([ - '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(json_encode([ - '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(json_encode([ - '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 822ef4e..0000000 --- a/tests/Channels/PresenceChannelReplicationTest.php +++ /dev/null @@ -1,136 +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(json_encode([ - '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', [ - '1234:presence-channel', - $connection->socketId, - json_encode($channelData), - ]) - ->assertCalledWithArgs('hgetall', ['1234:presence-channel']) - ->assertCalled('publish'); - } - - /** @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(json_encode([ - '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', ['1234:presence-channel']) - ->assertCalled('publish'); - - $this->getPublishClient() - ->resetAssertions(); - - $message = new Message(json_encode([ - '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(json_encode([ - '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', ['1234:presence-channel']) - ->assertCalled('publish'); - } -} diff --git a/tests/Channels/PresenceChannelTest.php b/tests/Channels/PresenceChannelTest.php deleted file mode 100644 index a72d94f..0000000 --- a/tests/Channels/PresenceChannelTest.php +++ /dev/null @@ -1,165 +0,0 @@ -expectException(InvalidSignature::class); - - $connection = $this->getWebSocketConnection(); - - $message = new Message(json_encode([ - '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(json_encode([ - '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(json_encode([ - '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(json_encode([ - '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(json_encode([ - '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(json_encode([ - '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 cc4bab7..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(json_encode([ - '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(json_encode([ - '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 6b8d9b6..0000000 --- a/tests/Channels/PrivateChannelTest.php +++ /dev/null @@ -1,56 +0,0 @@ -expectException(InvalidSignature::class); - - $connection = $this->getWebSocketConnection(); - - $message = new Message(json_encode([ - '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(json_encode([ - '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 91f7790..0000000 --- a/tests/Commands/CleanStatisticsTest.php +++ /dev/null @@ -1,45 +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()); - } -} 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..08f71a3 --- /dev/null +++ b/tests/Commands/StartServerTest.php @@ -0,0 +1,51 @@ +loop->futureTick(function () { + $this->loop->stop(); + }); + + $this->artisan('websockets:serve', ['--loop' => $this->loop, '--debug' => true, '--port' => 6001]); + + $this->assertTrue(true); + } + + public function test_pcntl_sigint_signal() + { + $this->loop->futureTick(function () { + $this->newActiveConnection(['public-channel']); + $this->newActiveConnection(['public-channel']); + + posix_kill(posix_getpid(), SIGINT); + + $this->loop->stop(); + }); + + $this->artisan('websockets:serve', ['--loop' => $this->loop, '--debug' => true, '--port' => 6002]); + + $this->assertTrue(true); + } + + public function test_pcntl_sigterm_signal() + { + $this->loop->futureTick(function () { + $this->newActiveConnection(['public-channel']); + $this->newActiveConnection(['public-channel']); + + posix_kill(posix_getpid(), SIGTERM); + + $this->loop->stop(); + }); + + $this->artisan('websockets:serve', ['--loop' => $this->loop, '--debug' => true, '--port' => 6003]); + + $this->assertTrue(true); + } +} diff --git a/tests/Commands/StartWebSocketServerTest.php b/tests/Commands/StartWebSocketServerTest.php deleted file mode 100644 index 637c1c8..0000000 --- a/tests/Commands/StartWebSocketServerTest.php +++ /dev/null @@ -1,16 +0,0 @@ -artisan('websockets:serve', ['--test' => 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 0aba6ec..2e4f2ed 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -1,103 +1,128 @@ 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() + { + $connection = $this->newActiveConnection(['public-channel']); + + $this->channelManager + ->getGlobalChannels('1234') + ->then(function ($channels) { + $this->assertCount(1, $channels); + }); + + $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); + }); + } + + public function test_websocket_exceptions_are_sent() + { + $connection = $this->newActiveConnection(['public-channel']); + + $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->expectException(ConnectionsOverCapacity::class); - $this->getConnectedWebSocketConnection(['test-channel']); + $this->newActiveConnection(['test-channel']); + $this->newActiveConnection(['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() + public function test_close_all_new_connections_after_stating_the_server_does_not_accept_new_connections() { - $connection = $this->getWebSocketConnection(); + $this->newActiveConnection(['test-channel']) + ->assertSentEvent('pusher:connection_established') + ->assertSentEvent('pusher_internal:subscription_succeeded'); - $this->pusherServer->onOpen($connection); + $this->channelManager->declineNewConnections(); - $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); - } + $this->assertFalse( + $this->channelManager->acceptsNewConnections() + ); - /** @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'); + $this->newActiveConnection(['test-channel']) + ->assertNothingSent() + ->assertClosed(); } } diff --git a/tests/Dashboard/AuthTest.php b/tests/Dashboard/AuthTest.php new file mode 100644 index 0000000..bc67361 --- /dev/null +++ b/tests/Dashboard/AuthTest.php @@ -0,0 +1,89 @@ +newActiveConnection(['test-channel']); + + $this->pusherServer->onOpen($connection); + + $this->actingAs(factory(User::class)->create()) + ->json('POST', route('laravel-websockets.auth'), [ + 'socket_id' => $connection->socketId, + 'channel_name' => 'test-channel', + ], ['x-app-id' => '1234']) + ->seeJsonStructure([ + 'auth', + 'channel_data', + ]); + } + + public function test_can_authenticate_dashboard_over_private_channel() + { + $connection = $this->newConnection(); + + $this->pusherServer->onOpen($connection); + + $message = new SignedMessage([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'channel' => 'private-channel', + ], + ], $connection, 'private-channel'); + + $this->pusherServer->onMessage($connection, $message); + + $connection->assertSentEvent('pusher_internal:subscription_succeeded', [ + 'channel' => 'private-channel', + ]); + + $this->actingAs(factory(User::class)->create()) + ->json('POST', route('laravel-websockets.auth'), [ + 'socket_id' => $connection->socketId, + 'channel_name' => 'private-test-channel', + ], ['x-app-id' => '1234']) + ->seeJsonStructure([ + 'auth', + ]); + } + + public function test_can_authenticate_dashboard_over_presence_channel() + { + $connection = $this->newConnection(); + + $this->pusherServer->onOpen($connection); + + $user = json_encode([ + 'user_id' => 1, + 'user_info' => [ + 'name' => 'Rick', + ], + ]); + + $message = new SignedMessage([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'channel' => 'presence-channel', + 'channel_data' => $user, + ], + ], $connection, 'presence-channel', $user); + + $this->pusherServer->onMessage($connection, $message); + + $this->actingAs(factory(User::class)->create()) + ->json('POST', route('laravel-websockets.auth'), [ + 'socket_id' => $connection->socketId, + 'channel_name' => 'presence-channel', + ], ['x-app-id' => '1234']) + ->seeJsonStructure([ + 'auth', + ]); + } +} diff --git a/tests/Dashboard/DashboardTest.php b/tests/Dashboard/DashboardTest.php new file mode 100644 index 0000000..d25d1e0 --- /dev/null +++ b/tests/Dashboard/DashboardTest.php @@ -0,0 +1,23 @@ +get(route('laravel-websockets.dashboard')) + ->assertResponseStatus(403); + } + + public function test_can_see_dashboard() + { + $this->actingAs(factory(User::class)->create()) + ->get(route('laravel-websockets.dashboard')) + ->assertResponseOk() + ->see('WebSockets Dashboard'); + } +} diff --git a/tests/Dashboard/SendMessageTest.php b/tests/Dashboard/SendMessageTest.php new file mode 100644 index 0000000..64cd887 --- /dev/null +++ b/tests/Dashboard/SendMessageTest.php @@ -0,0 +1,43 @@ +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, + ]); + + $this->markTestIncomplete( + 'Broadcasting is not possible to be tested without receiving a Pusher error.' + ); + } + + public function test_cant_send_message_for_invalid_app() + { + $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']), + ]) + ->assertResponseStatus(422); + } +} diff --git a/tests/Dashboard/StatisticsTest.php b/tests/Dashboard/StatisticsTest.php new file mode 100644 index 0000000..fe5ab50 --- /dev/null +++ b/tests/Dashboard/StatisticsTest.php @@ -0,0 +1,42 @@ +newActiveConnection(['public-channel']); + $morty = $this->newActiveConnection(['public-channel']); + + $this->statisticsCollector->save(); + + $response = $this->actingAs(factory(User::class)->create()) + ->json('GET', route('laravel-websockets.statistics', ['appId' => '1234'])) + ->assertResponseOk() + ->seeJsonStructure([ + 'peak_connections' => ['x', 'y'], + 'websocket_messages_count' => ['x', 'y'], + 'api_messages_count' => ['x', 'y'], + ]); + } + + public function test_cant_get_statistics_for_invalid_app_id() + { + $rick = $this->newActiveConnection(['public-channel']); + $morty = $this->newActiveConnection(['public-channel']); + + $this->statisticsCollector->save(); + + $this->actingAs(factory(User::class)->create()) + ->json('GET', route('laravel-websockets.statistics', ['appId' => 'not_found'])) + ->seeJson([ + 'peak_connections' => ['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..9e4dd64 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', ['user_id' => 1]); + $this->newPresenceConnection('presence-channel', ['user_id' => 2]); - $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..b0b08c4 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', ['user_id' => 1]); + $this->newPresenceConnection('presence-global.1', ['user_id' => 2]); + $this->newPresenceConnection('presence-global.2', ['user_id' => 3]); + $this->newPresenceConnection('presence-notglobal.2', ['user_id' => 4]); - $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/FetchUsersTest.php b/tests/FetchUsersTest.php new file mode 100644 index 0000000..0a5fc09 --- /dev/null +++ b/tests/FetchUsersTest.php @@ -0,0 +1,149 @@ +expectException(HttpException::class); + $this->expectExceptionMessage('Invalid auth signature provided.'); + + $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 + ); + + $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $controller = app(FetchUsers::class); + + $controller->onOpen($connection, $request); + } + + public function test_it_only_returns_data_for_presence_channels() + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Invalid presence channel'); + + $this->newActiveConnection(['my-channel']); + + $connection = new Mocks\Connection; + + $requestPath = '/apps/1234/channel/my-channel/users'; + + $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(FetchUsers::class); + + $controller->onOpen($connection, $request); + } + + public function test_it_returns_404_for_invalid_channels() + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Invalid presence channel'); + + $this->newActiveConnection(['my-channel']); + + $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 + ); + + $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $controller = app(FetchUsers::class); + + $controller->onOpen($connection, $request); + } + + public function test_it_returns_connected_user_information() + { + $this->newPresenceConnection('presence-channel'); + + $connection = new Mocks\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(FetchUsers::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)); + } + + public function test_multiple_clients_with_same_id_gets_counted_once() + { + $rick = $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + $morty = $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + + $connection = new Mocks\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(FetchUsers::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/HealthTest.php b/tests/HealthTest.php new file mode 100644 index 0000000..61da8ef --- /dev/null +++ b/tests/HealthTest.php @@ -0,0 +1,22 @@ +newConnection(); + + $this->pusherServer = app(HealthHandler::class); + + $this->pusherServer->onOpen($connection); + + $this->assertTrue( + Str::contains($connection->sentRawData[0], '{"ok":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 ac87a62..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', ['1234:presence-channel']) - ->assertCalled('publish') - ->assertCalled('multi') - ->assertCalledWithArgs('hlen', ['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', ['1234:presence-global.1']) - ->assertCalledWithArgs('hgetall', ['1234:presence-global.2']) - ->assertCalledWithArgs('hgetall', ['1234:presence-notglobal.2']) - ->assertCalled('publish') - ->assertCalled('multi') - ->assertCalledWithArgs('hlen', ['1234:presence-global.1']) - ->assertCalledWithArgs('hlen', ['1234:presence-global.2']) - ->assertNotCalledWithArgs('hlen', ['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', ['1234:presence-global.1']) - ->assertCalledWithArgs('hgetall', ['1234:presence-global.2']) - ->assertCalledWithArgs('hgetall', ['1234:presence-notglobal.2']) - ->assertCalled('publish') - ->assertCalled('multi') - ->assertCalledWithArgs('hlen', ['1234:presence-global.1']) - ->assertCalledWithArgs('hlen', ['1234:presence-global.2']) - ->assertNotCalledWithArgs('hlen', ['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/HttpApi/FetchUsersTest.php b/tests/HttpApi/FetchUsersTest.php deleted file mode 100644 index f68af14..0000000 --- a/tests/HttpApi/FetchUsersTest.php +++ /dev/null @@ -1,121 +0,0 @@ -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/LocalPongRemovalTest.php b/tests/LocalPongRemovalTest.php new file mode 100644 index 0000000..fa643e4 --- /dev/null +++ b/tests/LocalPongRemovalTest.php @@ -0,0 +1,131 @@ +runOnlyOnLocalReplication(); + + $activeConnection = $this->newActiveConnection(['public-channel']); + $obsoleteConnection = $this->newActiveConnection(['public-channel']); + + // The active connection just pinged, it should not be closed. + $activeConnection->lastPongedAt = Carbon::now(); + $obsoleteConnection->lastPongedAt = Carbon::now()->subDays(1); + + $this->channelManager->updateConnectionInChannels($activeConnection); + $this->channelManager->updateConnectionInChannels($obsoleteConnection); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'public-channel') + ->then(function ($count) { + $this->assertEquals(2, $count); + }); + + $this->channelManager->removeObsoleteConnections(); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'public-channel') + ->then(function ($count) { + $this->assertEquals(1, $count); + }); + + $this->channelManager + ->getLocalConnections() + ->then(function ($connections) use ($activeConnection) { + $connection = $connections[$activeConnection->socketId]; + + $this->assertEquals($activeConnection->socketId, $connection->socketId); + }); + } + + public function test_not_ponged_connections_do_get_removed_on_local_for_private_channels() + { + $this->runOnlyOnLocalReplication(); + + $activeConnection = $this->newPrivateConnection('private-channel'); + $obsoleteConnection = $this->newPrivateConnection('private-channel'); + + // The active connection just pinged, it should not be closed. + $activeConnection->lastPongedAt = Carbon::now(); + $obsoleteConnection->lastPongedAt = Carbon::now()->subDays(1); + + $this->channelManager->updateConnectionInChannels($activeConnection); + $this->channelManager->updateConnectionInChannels($obsoleteConnection); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'private-channel') + ->then(function ($count) { + $this->assertEquals(2, $count); + }); + + $this->channelManager->removeObsoleteConnections(); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'private-channel') + ->then(function ($count) { + $this->assertEquals(1, $count); + }); + + $this->channelManager + ->getLocalConnections() + ->then(function ($connections) use ($activeConnection) { + $connection = $connections[$activeConnection->socketId]; + + $this->assertEquals($activeConnection->socketId, $connection->socketId); + }); + } + + public function test_not_ponged_connections_do_get_removed_on_local_for_presence_channels() + { + $this->runOnlyOnLocalReplication(); + + $activeConnection = $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + $obsoleteConnection = $this->newPresenceConnection('presence-channel', ['user_id' => 2]); + + // The active connection just pinged, it should not be closed. + $activeConnection->lastPongedAt = Carbon::now(); + $obsoleteConnection->lastPongedAt = Carbon::now()->subDays(1); + + $this->channelManager->updateConnectionInChannels($activeConnection); + $this->channelManager->updateConnectionInChannels($obsoleteConnection); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'presence-channel') + ->then(function ($count) { + $this->assertEquals(2, $count); + }); + + $this->channelManager + ->getChannelMembers('1234', 'presence-channel') + ->then(function ($members) { + $this->assertCount(2, $members); + }); + + $this->channelManager->removeObsoleteConnections(); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'presence-channel') + ->then(function ($count) { + $this->assertEquals(1, $count); + }); + + $this->channelManager + ->getLocalConnections() + ->then(function ($connections) use ($activeConnection) { + $connection = $connections[$activeConnection->socketId]; + + $this->assertEquals($activeConnection->socketId, $connection->socketId); + }); + + $this->channelManager + ->getChannelMembers('1234', 'presence-channel') + ->then(function ($members) { + $this->assertCount(1, $members); + }); + } +} diff --git a/tests/Messages/PusherClientMessageTest.php b/tests/Messages/PusherClientMessageTest.php deleted file mode 100644 index a97aed7..0000000 --- a/tests/Messages/PusherClientMessageTest.php +++ /dev/null @@ -1,63 +0,0 @@ -getConnectedWebSocketConnection(['test-channel']); - - $message = new Message(json_encode([ - '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(json_encode([ - '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 2e9c606..e4d6e1f 100644 --- a/tests/Mocks/Connection.php +++ b/tests/Mocks/Connection.php @@ -1,6 +1,6 @@ sentData[] = json_decode($data, true); $this->sentRawData[] = $data; } + /** + * Mark the connection as closed. + * + * @return void + */ public function close() { $this->closed = true; } + /** + * Reset the events for assertions. + * + * @return $this + */ + public function resetEvents() + { + $this->sentData = []; + $this->sentRawData = []; + + return $this; + } + + /** + * Dump & stop execution. + * + * @return void + */ + public function dd() + { + dd([ + 'sentData' => $this->sentData, + 'sentRawData' => $this->sentRawData, + ]); + } + + /** + * Assert that an event got sent. + * + * @param string $name + * @param array $additionalParameters + * @return $this + */ public function assertSentEvent(string $name, array $additionalParameters = []) { $event = collect($this->sentData)->firstWhere('event', '=', $name); @@ -39,8 +102,16 @@ class Connection implements ConnectionInterface foreach ($additionalParameters as $parameter => $value) { PHPUnit::assertSame($event[$parameter], $value); } + + return $this; } + /** + * Assert that an event got not sent. + * + * @param string $name + * @return $this + */ public function assertNotSentEvent(string $name) { $event = collect($this->sentData)->firstWhere('event', '=', $name); @@ -48,10 +119,31 @@ class Connection implements ConnectionInterface PHPUnit::assertTrue( is_null($event) ); + + return $this; } + /** + * Assert that no events occured within the connection. + * + * @return $this + */ + public function assertNothingSent() + { + PHPUnit::assertEquals([], $this->sentData); + + return $this; + } + + /** + * Assert the connection is closed. + * + * @return $this + */ public function assertClosed() { PHPUnit::assertTrue($this->closed); + + return $this; } } diff --git a/tests/Mocks/LazyClient.php b/tests/Mocks/LazyClient.php index ab3e224..539e7db 100644 --- a/tests/Mocks/LazyClient.php +++ b/tests/Mocks/LazyClient.php @@ -1,9 +1,12 @@ loop = $loop; + $this->redis = Redis::connection(); + } + /** * {@inheritdoc} */ @@ -28,7 +56,17 @@ class LazyClient extends BaseLazyClient { $this->calls[] = [$name, $args]; - return parent::__call($name, $args); + if (! in_array($name, ['subscribe', 'psubscribe', 'unsubscribe', 'punsubscribe', 'onMessage'])) { + if ($name === 'eval') { + $this->redis->{$name}(...$args); + } else { + $this->redis->__call($name, $args); + } + } + + return new PromiseResolver( + parent::__call($name, $args), $this->loop + ); } /** @@ -64,6 +102,26 @@ class LazyClient extends BaseLazyClient return $this; } + /** + * Check if the method got called. + * + * @param int $times + * @param string $name + * @return $this + */ + public function assertCalledCount(int $times, string $name) + { + $total = collect($this->getCalledFunctions())->filter(function ($function) use ($name) { + [$calledName, ] = $function; + + return $calledName === $name; + }); + + PHPUnit::assertCount($times, $total); + + return $this; + } + /** * Check if the method with args got called. * @@ -71,7 +129,7 @@ class LazyClient extends BaseLazyClient * @param array $args * @return $this */ - public function assertCalledWithArgs($name, array $args) + public function assertCalledWithArgs(string $name, array $args) { foreach ($this->getCalledFunctions() as $function) { [$calledName, $calledArgs] = $function; @@ -88,13 +146,34 @@ class LazyClient extends BaseLazyClient return $this; } + /** + * Check if the method with args got called an amount of times. + * + * @param int $times + * @param string $name + * @param array $args + * @return $this + */ + public function assertCalledWithArgsCount(int $times, string $name, array $args) + { + $total = collect($this->getCalledFunctions())->filter(function ($function) use ($name, $args) { + [$calledName, $calledArgs] = $function; + + return $calledName === $name && $calledArgs === $args; + }); + + PHPUnit::assertCount($times, $total); + + return $this; + } + /** * Check if the method didn't call. * * @param string $name * @return $this */ - public function assertNotCalled($name) + public function assertNotCalled(string $name) { foreach ($this->getCalledFunctions() as $function) { [$calledName, ] = $function; @@ -118,7 +197,7 @@ class LazyClient extends BaseLazyClient * @param array $args * @return $this */ - public function assertNotCalledWithArgs($name, array $args) + public function assertNotCalledWithArgs(string $name, array $args) { foreach ($this->getCalledFunctions() as $function) { [$calledName, $calledArgs] = $function; @@ -135,6 +214,27 @@ class LazyClient extends BaseLazyClient return $this; } + /** + * Check if the method with args got called an amount of times. + * + * @param int $times + * @param string $name + * @param array $args + * @return $this + */ + public function assertNotCalledWithArgsCount(int $times, string $name, array $args) + { + $total = collect($this->getCalledFunctions())->filter(function ($function) use ($name, $args) { + [$calledName, $calledArgs] = $function; + + return $calledName === $name && $calledArgs === $args; + }); + + PHPUnit::assertNotCount($times, $total); + + return $this; + } + /** * Check if no function got called. * diff --git a/tests/Mocks/Message.php b/tests/Mocks/Message.php index 3b0706c..04a5a1a 100644 --- a/tests/Mocks/Message.php +++ b/tests/Mocks/Message.php @@ -1,17 +1,55 @@ payload = $payload; } - public function getPayload() + /** + * Get the payload as json-encoded string. + * + * @return string + */ + public function getPayload(): string + { + return json_encode($this->payload); + } + + /** + * Get the payload as object. + * + * @return stdClass + */ + public function getPayloadAsObject() + { + return json_decode($this->getPayload()); + } + + /** + * Get the payload as array. + * + * @return stdClass + */ + public function getPayloadAsArray(): array { return $this->payload; } diff --git a/tests/Mocks/PromiseResolver.php b/tests/Mocks/PromiseResolver.php new file mode 100644 index 0000000..66f8480 --- /dev/null +++ b/tests/Mocks/PromiseResolver.php @@ -0,0 +1,72 @@ +promise = $promise instanceof PromiseInterface ? $promise : new FulfilledPromise($promise); + $this->loop = $loop; + } + + /** + * Intercept the promise then() and run it in sync. + * + * @param callable|null $onFulfilled + * @param callable|null $onRejected + * @param callable|null $onProgress + * @return PromiseInterface + */ + public function then(callable $onFulfilled = null, callable $onRejected = null, callable $onProgress = null) + { + $result = Block\await( + $this->promise, $this->loop + ); + + $result = call_user_func($onFulfilled, $result); + + return $result instanceof PromiseInterface + ? new self($result, $this->loop) + : new self(Helpers::createFulfilledPromise($result), $this->loop); + } + + /** + * Pass the calls to the promise. + * + * @param string $method + * @param array $args + * @return mixed + */ + public function __call($method, $args) + { + return call_user_func([$this->promise, $method], $args); + } +} 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 @@ socketId}:{$channelName}"; + + if ($encodedUser) { + $signature .= ":{$encodedUser}"; + } + + $hash = hash_hmac('sha256', $signature, $connection->app->secret); + + $this->payload['data']['auth'] = "{$connection->app->key}:{$hash}"; + } +} diff --git a/tests/Models/User.php b/tests/Models/User.php new file mode 100644 index 0000000..5ca4c63 --- /dev/null +++ b/tests/Models/User.php @@ -0,0 +1,16 @@ +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..d983c78 --- /dev/null +++ b/tests/PresenceChannelTest.php @@ -0,0 +1,551 @@ +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); + + $message = new Mocks\SignedMessage([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'channel' => 'presence-channel', + 'channel_data' => $encodedUser, + ], + ], $connection, 'presence-channel', $encodedUser); + + $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_connect_to_presence_channel_when_user_with_same_ids_is_already_joined() + { + $rick = $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + $morty = $this->newPresenceConnection('presence-channel', ['user_id' => 2]); + $pickleRick = $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + + foreach ([$rick, $morty, $pickleRick] as $connection) { + $connection->assertSentEvent('pusher_internal:subscription_succeeded', [ + 'channel' => 'presence-channel', + ]); + } + + $rick->assertSentEvent('pusher_internal:subscription_succeeded', [ + 'channel' => 'presence-channel', + 'data' => json_encode([ + 'presence' => [ + 'ids' => ['1'], + 'hash' => ['1' => []], + 'count' => 1, + ], + ]), + ]); + + $morty->assertSentEvent('pusher_internal:subscription_succeeded', [ + 'channel' => 'presence-channel', + 'data' => json_encode([ + 'presence' => [ + 'ids' => ['1', '2'], + 'hash' => ['1' => [], '2' => []], + 'count' => 2, + ], + ]), + ]); + + // The duplicated-user_id connection should get basically the list of ids + // without dealing with duplicate user ids. + $pickleRick->assertSentEvent('pusher_internal:subscription_succeeded', [ + 'channel' => 'presence-channel', + 'data' => json_encode([ + 'presence' => [ + 'ids' => ['1', '2'], + 'hash' => ['1' => [], '2' => []], + 'count' => 2, + ], + ]), + ]); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'presence-channel') + ->then(function ($total) { + $this->assertEquals(3, $total); + }); + + $this->channelManager + ->getChannelMembers('1234', 'presence-channel') + ->then(function ($members) { + $this->assertCount(2, $members); + }); + } + + 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) use ($rick) { + $this->assertCount(1, $members); + $this->assertEquals(1, $members[$rick->socketId]->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()); + }); + } + + public function test_local_connections_for_presence_channels() + { + $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + $this->newPresenceConnection('presence-channel-2', ['user_id' => 2]); + + $this->channelManager + ->getLocalConnections() + ->then(function ($connections) { + $this->assertCount(2, $connections); + + foreach ($connections as $connection) { + $this->assertInstanceOf( + ConnectionInterface::class, $connection + ); + } + }); + } + + public function test_multiple_clients_with_same_user_id_trigger_member_added_and_removed_event_only_on_first_and_last_socket_connection() + { + // Connect the `observer` user to the server + $observerConnection = $this->newPresenceConnection('presence-channel', ['user_id' => 'observer']); + + // Connect the first socket for user `1` to the server + $firstConnection = $this->newPresenceConnection('presence-channel', ['user_id' => '1']); + + // Make sure the observer sees a `member_added` event for `user:1` + $observerConnection->assertSentEvent('pusher_internal:member_added', [ + 'event' => 'pusher_internal:member_added', + 'channel' => 'presence-channel', + 'data' => json_encode(['user_id' => '1']), + ])->resetEvents(); + + // Connect the second socket for user `1` to the server + $secondConnection = $this->newPresenceConnection('presence-channel', ['user_id' => '1']); + + // Make sure the observer was not notified of a `member_added` event (user was already connected) + $observerConnection->assertNotSentEvent('pusher_internal:member_added'); + + // Disconnect the first socket for user `1` on the server + $this->pusherServer->onClose($firstConnection); + + // Make sure the observer was not notified of a `member_removed` event (user still connected on another socket) + $observerConnection->assertNotSentEvent('pusher_internal:member_removed'); + + // Disconnect the second (and last) socket for user `1` on the server + $this->pusherServer->onClose($secondConnection); + + // Make sure the observer was notified of a `member_removed` event (last socket for user was disconnected) + $observerConnection->assertSentEvent('pusher_internal:member_removed'); + + $this->channelManager + ->getMemberSockets('1', '1234', 'presence-channel') + ->then(function ($sockets) { + $this->assertCount(0, $sockets); + }); + + $this->channelManager + ->getMemberSockets('2', '1234', 'presence-channel') + ->then(function ($sockets) { + $this->assertCount(0, $sockets); + }); + + $this->channelManager + ->getMemberSockets('observer', '1234', 'presence-channel') + ->then(function ($sockets) { + $this->assertCount(1, $sockets); + }); + } + + public function test_events_are_processed_by_on_message_on_presence_channels() + { + $this->runOnlyOnRedisReplication(); + + $user = [ + 'user_id' => 1, + 'user_info' => [ + 'name' => 'Rick', + ], + ]; + + $connection = $this->newPresenceConnection('presence-channel', $user); + + $encodedUser = json_encode($user); + + $message = new Mocks\SignedMessage([ + 'appId' => '1234', + 'serverId' => 'different_server_id', + 'event' => 'some-event', + 'data' => [ + 'channel' => 'presence-channel', + 'channel_data' => $encodedUser, + 'test' => 'yes', + ], + ], $connection, 'presence-channel', $encodedUser); + + $this->channelManager->onMessage( + $this->channelManager->getRedisKey('1234', 'presence-channel'), + $message->getPayload() + ); + + // The message does not contain appId and serverId anymore. + $message = new Mocks\SignedMessage([ + 'event' => 'some-event', + 'data' => [ + 'channel' => 'presence-channel', + 'channel_data' => $encodedUser, + 'test' => 'yes', + ], + ], $connection, 'presence-channel', $encodedUser); + + $connection->assertSentEvent('some-event', $message->getPayloadAsArray()); + } + + public function test_events_get_replicated_across_connections_for_presence_channels() + { + $this->runOnlyOnRedisReplication(); + + $connection = $this->newPresenceConnection('presence-channel'); + $receiver = $this->newPresenceConnection('presence-channel', ['user_id' => 2]); + + $user = [ + 'user_id' => 1, + 'user_info' => [ + 'name' => 'Rick', + ], + ]; + + $encodedUser = json_encode($user); + + $message = new Mocks\SignedMessage([ + 'appId' => '1234', + 'serverId' => $this->channelManager->getServerId(), + 'event' => 'some-event', + 'data' => [ + 'channel' => 'presence-channel', + 'channel_data' => $encodedUser, + 'test' => 'yes', + ], + 'socketId' => $connection->socketId, + ], $connection, 'presence-channel', $encodedUser); + + $channel = $this->channelManager->find('1234', 'presence-channel'); + + $channel->broadcastToEveryoneExcept( + $message->getPayloadAsObject(), $connection->socketId, '1234', true + ); + + $receiver->assertSentEvent('some-event', $message->getPayloadAsArray()); + + $this->getSubscribeClient() + ->assertNothingDispatched(); + + $this->getPublishClient() + ->assertCalledWithArgs('publish', [ + $this->channelManager->getRedisKey('1234', 'presence-channel'), + $message->getPayload(), + ]); + } + + 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', 'POST', $requestPath, [ + 'name' => 'some-event', + 'channels' => ['presence-channel'], + 'data' => json_encode(['some-data' => 'yes']), + ], + ); + + $request = new Request('POST', "{$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 ($statistic) { + $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_when_there_are_not_users_locally_for_presence_channel() + { + $connection = new Mocks\Connection; + + $requestPath = '/apps/1234/events'; + + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'TestSecret', 'POST', $requestPath, [ + 'name' => 'some-event', + 'channels' => ['presence-channel'], + 'data' => json_encode(['some-data' => 'yes']), + ], + ); + + $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $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() + ->assertCalledWithArgsCount(1, 'publish', [ + $this->channelManager->getRedisKey('1234', 'presence-channel'), + json_encode([ + 'event' => 'some-event', + 'channel' => 'presence-channel', + 'data' => json_encode(['some-data' => 'yes']), + 'appId' => '1234', + 'socketId' => null, + 'serverId' => $this->channelManager->getServerId(), + ]), + ]); + } + } + + public function test_it_fires_event_across_servers_when_there_are_users_locally_for_presence_channel() + { + $wsConnection = $this->newPresenceConnection('presence-channel'); + + $connection = new Mocks\Connection; + + $requestPath = '/apps/1234/events'; + + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'TestSecret', 'POST', $requestPath, [ + 'name' => 'some-event', + 'channels' => ['presence-channel'], + 'data' => json_encode(['some-data' => 'yes']), + ], + ); + + $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $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() + ->assertCalledWithArgsCount(1, 'publish', [ + $this->channelManager->getRedisKey('1234', 'presence-channel'), + json_encode([ + 'event' => 'some-event', + 'channel' => 'presence-channel', + 'data' => json_encode(['some-data' => 'yes']), + 'appId' => '1234', + 'socketId' => null, + 'serverId' => $this->channelManager->getServerId(), + ]), + ]); + } + + $wsConnection->assertSentEvent('some-event', [ + 'channel' => 'presence-channel', + 'data' => json_encode(['some-data' => 'yes']), + ]); + } +} diff --git a/tests/PrivateChannelTest.php b/tests/PrivateChannelTest.php new file mode 100644 index 0000000..90efa6d --- /dev/null +++ b/tests/PrivateChannelTest.php @@ -0,0 +1,371 @@ +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); + + $message = new Mocks\SignedMessage([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'channel' => 'private-channel', + ], + ], $connection, '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()); + }); + } + + public function test_local_connections_for_private_channels() + { + $this->newPrivateConnection('private-channel'); + $this->newPrivateConnection('private-channel-2'); + + $this->channelManager + ->getLocalConnections() + ->then(function ($connections) { + $this->assertCount(2, $connections); + + foreach ($connections as $connection) { + $this->assertInstanceOf( + ConnectionInterface::class, $connection + ); + } + }); + } + + public function test_events_are_processed_by_on_message_on_private_channels() + { + $this->runOnlyOnRedisReplication(); + + $connection = $this->newPrivateConnection('private-channel'); + + $message = new Mocks\SignedMessage([ + 'appId' => '1234', + 'serverId' => 'different_server_id', + 'event' => 'some-event', + 'data' => [ + 'channel' => 'private-channel', + 'test' => 'yes', + ], + ], $connection, 'private-channel'); + + $this->channelManager->onMessage( + $this->channelManager->getRedisKey('1234', 'private-channel'), + $message->getPayload() + ); + + // The message does not contain appId and serverId anymore. + $message = new Mocks\SignedMessage([ + 'event' => 'some-event', + 'data' => [ + 'channel' => 'private-channel', + 'test' => 'yes', + ], + ], $connection, 'private-channel'); + + $connection->assertSentEvent('some-event', $message->getPayloadAsArray()); + } + + public function test_events_get_replicated_across_connections_for_private_channels() + { + $this->runOnlyOnRedisReplication(); + + $connection = $this->newPrivateConnection('private-channel'); + $receiver = $this->newPrivateConnection('private-channel'); + + $message = new Mocks\SignedMessage([ + 'appId' => '1234', + 'serverId' => $this->channelManager->getServerId(), + 'event' => 'some-event', + 'data' => [ + 'channel' => 'private-channel', + 'test' => 'yes', + ], + 'socketId' => $connection->socketId, + ], $connection, 'private-channel'); + + $channel = $this->channelManager->find('1234', 'private-channel'); + + $channel->broadcastToEveryoneExcept( + $message->getPayloadAsObject(), $connection->socketId, '1234', true + ); + + $receiver->assertSentEvent('some-event', $message->getPayloadAsArray()); + + $this->getSubscribeClient() + ->assertNothingDispatched(); + + $this->getPublishClient() + ->assertCalledWithArgs('publish', [ + $this->channelManager->getRedisKey('1234', 'private-channel'), + $message->getPayload(), + ]); + } + + public function test_it_fires_the_event_to_private_channel() + { + $this->newPrivateConnection('private-channel'); + + $connection = new Mocks\Connection; + + $requestPath = '/apps/1234/events'; + + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'TestSecret', 'POST', $requestPath, [ + 'name' => 'some-event', + 'channels' => ['private-channel'], + 'data' => json_encode(['some-data' => 'yes']), + ], + ); + + $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $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 ($statistic) { + $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_when_there_are_not_users_locally_for_private_channel() + { + $connection = new Mocks\Connection; + + $requestPath = '/apps/1234/events'; + + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'TestSecret', 'POST', $requestPath, [ + 'name' => 'some-event', + 'channels' => ['private-channel'], + 'data' => json_encode(['some-data' => 'yes']), + ], + ); + + $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $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() + ->assertCalledWithArgsCount(1, 'publish', [ + $this->channelManager->getRedisKey('1234', 'private-channel'), + json_encode([ + 'event' => 'some-event', + 'channel' => 'private-channel', + 'data' => json_encode(['some-data' => 'yes']), + 'appId' => '1234', + 'socketId' => null, + 'serverId' => $this->channelManager->getServerId(), + ]), + ]); + } + } + + public function test_it_fires_event_across_servers_when_there_are_users_locally_for_private_channel() + { + $wsConnection = $this->newPrivateConnection('private-channel'); + + $connection = new Mocks\Connection; + + $requestPath = '/apps/1234/events'; + + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'TestSecret', 'POST', $requestPath, [ + 'name' => 'some-event', + 'channels' => ['private-channel'], + 'data' => json_encode(['some-data' => 'yes']), + ], + ); + + $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $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() + ->assertCalledWithArgsCount(1, 'publish', [ + $this->channelManager->getRedisKey('1234', 'private-channel'), + json_encode([ + 'event' => 'some-event', + 'channel' => 'private-channel', + 'data' => json_encode(['some-data' => 'yes']), + 'appId' => '1234', + 'socketId' => null, + 'serverId' => $this->channelManager->getServerId(), + ]), + ]); + } + + $wsConnection->assertSentEvent('some-event', [ + 'channel' => 'private-channel', + 'data' => json_encode(['some-data' => 'yes']), + ]); + } +} diff --git a/tests/PubSub/RedisDriverTest.php b/tests/PubSub/RedisDriverTest.php deleted file mode 100644 index e6585a1..0000000 --- a/tests/PubSub/RedisDriverTest.php +++ /dev/null @@ -1,49 +0,0 @@ -runOnlyOnRedisReplication(); - } - - /** @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, - 'socket' => $connection->socketId, - ]); - - $this->getSubscribeClient()->onMessage('1234:test-channel', $payload); - - $this->getSubscribeClient() - ->assertEventDispatched('message') - ->assertCalledWithArgs('subscribe', ['1234:test-channel']) - ->assertCalledWithArgs('onMessage', [ - '1234:test-channel', $payload, - ]); - } -} diff --git a/tests/PublicChannelTest.php b/tests/PublicChannelTest.php new file mode 100644 index 0000000..b16498d --- /dev/null +++ b/tests/PublicChannelTest.php @@ -0,0 +1,352 @@ +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()); + }); + } + + public function test_local_connections_for_public_channels() + { + $this->newActiveConnection(['public-channel']); + $this->newActiveConnection(['public-channel-2']); + + $this->channelManager + ->getLocalConnections() + ->then(function ($connections) { + $this->assertCount(2, $connections); + + foreach ($connections as $connection) { + $this->assertInstanceOf( + ConnectionInterface::class, $connection + ); + } + }); + } + + public function test_events_are_processed_by_on_message_on_public_channels() + { + $this->runOnlyOnRedisReplication(); + + $connection = $this->newActiveConnection(['public-channel']); + + $message = new Mocks\Message([ + 'appId' => '1234', + 'serverId' => 'different_server_id', + 'event' => 'some-event', + 'data' => [ + 'channel' => 'public-channel', + 'test' => 'yes', + ], + ]); + + $this->channelManager->onMessage( + $this->channelManager->getRedisKey('1234', 'public-channel'), + $message->getPayload() + ); + + // The message does not contain appId and serverId anymore. + $message = new Mocks\Message([ + 'event' => 'some-event', + 'data' => [ + 'channel' => 'public-channel', + 'test' => 'yes', + ], + ]); + + $connection->assertSentEvent('some-event', $message->getPayloadAsArray()); + } + + public function test_events_get_replicated_across_connections_for_public_channels() + { + $this->runOnlyOnRedisReplication(); + + $connection = $this->newActiveConnection(['public-channel']); + $receiver = $this->newActiveConnection(['public-channel']); + + $message = new Mocks\Message([ + 'appId' => '1234', + 'serverId' => $this->channelManager->getServerId(), + 'event' => 'some-event', + 'data' => [ + 'channel' => 'public-channel', + 'test' => 'yes', + ], + 'socketId' => $connection->socketId, + ]); + + $channel = $this->channelManager->find('1234', 'public-channel'); + + $channel->broadcastToEveryoneExcept( + $message->getPayloadAsObject(), $connection->socketId, '1234', true + ); + + $receiver->assertSentEvent('some-event', $message->getPayloadAsArray()); + + $this->getSubscribeClient() + ->assertNothingDispatched(); + + $this->getPublishClient() + ->assertCalledWithArgs('publish', [ + $this->channelManager->getRedisKey('1234', 'public-channel'), + $message->getPayload(), + ]); + } + + 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', 'POST', $requestPath, [ + 'name' => 'some-event', + 'channels' => ['public-channel'], + 'data' => json_encode(['some-data' => 'yes']), + ], + ); + + $request = new Request('POST', "{$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 ($statistic) { + $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_when_there_are_not_users_locally_for_public_channel() + { + $connection = new Mocks\Connection; + + $requestPath = '/apps/1234/events'; + + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'TestSecret', 'POST', $requestPath, [ + 'name' => 'some-event', + 'channels' => ['public-channel'], + 'data' => json_encode(['some-data' => 'yes']), + ], + ); + + $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $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() + ->assertCalledWithArgsCount(1, 'publish', [ + $this->channelManager->getRedisKey('1234', 'public-channel'), + json_encode([ + 'event' => 'some-event', + 'channel' => 'public-channel', + 'data' => json_encode(['some-data' => 'yes']), + 'appId' => '1234', + 'socketId' => null, + 'serverId' => $this->channelManager->getServerId(), + ]), + ]); + } + } + + public function test_it_fires_event_across_servers_when_there_are_users_locally_for_public_channel() + { + $wsConnection = $this->newActiveConnection(['public-channel']); + + $connection = new Mocks\Connection; + + $requestPath = '/apps/1234/events'; + + $routeParams = [ + 'appId' => '1234', + ]; + + $queryString = Pusher::build_auth_query_string( + 'TestKey', 'TestSecret', 'POST', $requestPath, [ + 'name' => 'some-event', + 'channels' => ['public-channel'], + 'data' => json_encode(['some-data' => 'yes']), + ], + ); + + $request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); + + $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() + ->assertCalledWithArgsCount(1, 'publish', [ + $this->channelManager->getRedisKey('1234', 'public-channel'), + json_encode([ + 'event' => 'some-event', + 'channel' => 'public-channel', + 'data' => json_encode(['some-data' => 'yes']), + 'appId' => '1234', + 'socketId' => null, + 'serverId' => $this->channelManager->getServerId(), + ]), + ]); + } + + $wsConnection->assertSentEvent('some-event', [ + 'channel' => 'public-channel', + 'data' => json_encode(['some-data' => 'yes']), + ]); + } +} diff --git a/tests/RedisPongRemovalTest.php b/tests/RedisPongRemovalTest.php new file mode 100644 index 0000000..14410fb --- /dev/null +++ b/tests/RedisPongRemovalTest.php @@ -0,0 +1,140 @@ +runOnlyOnRedisReplication(); + + $activeConnection = $this->newActiveConnection(['public-channel']); + $obsoleteConnection = $this->newActiveConnection(['public-channel']); + + // The active connection just pinged, it should not be closed. + $this->channelManager->addConnectionToSet($activeConnection, Carbon::now()); + + // Make the connection look like it was lost 1 day ago. + $this->channelManager->addConnectionToSet($obsoleteConnection, Carbon::now()->subDays(1)); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'public-channel') + ->then(function ($count) { + $this->assertEquals(2, $count); + }); + + $this->channelManager + ->getConnectionsFromSet(0, Carbon::now()->subMinutes(2)->format('U')) + ->then(function ($expiredConnections) { + $this->assertCount(1, $expiredConnections); + }); + + $this->channelManager->removeObsoleteConnections(); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'public-channel') + ->then(function ($count) { + $this->assertEquals(1, $count); + }); + + $this->channelManager + ->getConnectionsFromSet(0, Carbon::now()->subMinutes(2)->format('U')) + ->then(function ($expiredConnections) { + $this->assertCount(0, $expiredConnections); + }); + } + + public function test_not_ponged_connections_do_get_removed_on_redis_for_private_channels() + { + $this->runOnlyOnRedisReplication(); + + $activeConnection = $this->newPrivateConnection('private-channel'); + $obsoleteConnection = $this->newPrivateConnection('private-channel'); + + // The active connection just pinged, it should not be closed. + $this->channelManager->addConnectionToSet($activeConnection, Carbon::now()); + + // Make the connection look like it was lost 1 day ago. + $this->channelManager->addConnectionToSet($obsoleteConnection, Carbon::now()->subDays(1)); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'private-channel') + ->then(function ($count) { + $this->assertEquals(2, $count); + }); + + $this->channelManager + ->getConnectionsFromSet(0, Carbon::now()->subMinutes(2)->format('U')) + ->then(function ($expiredConnections) { + $this->assertCount(1, $expiredConnections); + }); + + $this->channelManager->removeObsoleteConnections(); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'private-channel') + ->then(function ($count) { + $this->assertEquals(1, $count); + }); + + $this->channelManager + ->getConnectionsFromSet(0, Carbon::now()->subMinutes(2)->format('U')) + ->then(function ($expiredConnections) { + $this->assertCount(0, $expiredConnections); + }); + } + + public function test_not_ponged_connections_do_get_removed_on_redis_for_presence_channels() + { + $this->runOnlyOnRedisReplication(); + + $activeConnection = $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + $obsoleteConnection = $this->newPresenceConnection('presence-channel', ['user_id' => 2]); + + // The active connection just pinged, it should not be closed. + $this->channelManager->addConnectionToSet($activeConnection, Carbon::now()); + + // Make the connection look like it was lost 1 day ago. + $this->channelManager->addConnectionToSet($obsoleteConnection, Carbon::now()->subDays(1)); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'presence-channel') + ->then(function ($count) { + $this->assertEquals(2, $count); + }); + + $this->channelManager + ->getConnectionsFromSet(0, Carbon::now()->subMinutes(2)->format('U')) + ->then(function ($expiredConnections) { + $this->assertCount(1, $expiredConnections); + }); + + $this->channelManager + ->getChannelMembers('1234', 'presence-channel') + ->then(function ($members) { + $this->assertCount(2, $members); + }); + + $this->channelManager->removeObsoleteConnections(); + + $this->channelManager + ->getGlobalConnectionsCount('1234', 'presence-channel') + ->then(function ($count) { + $this->assertEquals(1, $count); + }); + + $this->channelManager + ->getConnectionsFromSet(0, Carbon::now()->subMinutes(2)->format('U')) + ->then(function ($expiredConnections) { + $this->assertCount(0, $expiredConnections); + }); + + $this->channelManager + ->getChannelMembers('1234', 'presence-channel') + ->then(function ($members) { + $this->assertCount(1, $members); + }); + } +} diff --git a/tests/ReplicationTest.php b/tests/ReplicationTest.php new file mode 100644 index 0000000..30ef045 --- /dev/null +++ b/tests/ReplicationTest.php @@ -0,0 +1,36 @@ +runOnlyOnRedisReplication(); + } + + public function test_publishing_client_gets_subscribed() + { + $this->newActiveConnection(['public-channel']); + + $this->getSubscribeClient() + ->assertCalledWithArgs('subscribe', [$this->channelManager->getRedisKey('1234')]) + ->assertCalledWithArgs('subscribe', [$this->channelManager->getRedisKey('1234', 'public-channel')]); + } + + public function test_unsubscribe_from_topic_when_the_last_connection_leaves() + { + $connection = $this->newActiveConnection(['public-channel']); + + $this->pusherServer->onClose($connection); + + $this->getSubscribeClient() + ->assertCalledWithArgs('unsubscribe', [$this->channelManager->getRedisKey('1234')]) + ->assertCalledWithArgs('unsubscribe', [$this->channelManager->getRedisKey('1234', 'public-channel')]); + } +} diff --git a/tests/Statistics/Logger/FakeStatisticsLogger.php b/tests/Statistics/Logger/FakeStatisticsLogger.php deleted file mode 100644 index 629e627..0000000 --- a/tests/Statistics/Logger/FakeStatisticsLogger.php +++ /dev/null @@ -1,32 +0,0 @@ -statistics as $appId => $statistic) { - $currentConnectionCount = $this->channelManager->getConnectionCount($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/Statistics/Logger/StatisticsLoggerTest.php b/tests/Statistics/Logger/StatisticsLoggerTest.php deleted file mode 100644 index 49abd19..0000000 --- a/tests/Statistics/Logger/StatisticsLoggerTest.php +++ /dev/null @@ -1,46 +0,0 @@ -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']); - } -} 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 b0c7b7a..bcf7e28 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,36 +1,67 @@ pusherServer = app(config('websockets.handlers.websocket')); + $this->loop = LoopFactory::create(); - $this->channelManager = app(ChannelManager::class); + $this->replicationMode = getenv('REPLICATION_MODE') ?: 'local'; - StatisticsLogger::swap(new FakeStatisticsLogger( - $this->channelManager, - app(StatisticsDriver::class) - )); + $this->resetDatabase(); + $this->loadLaravelMigrations(['--database' => 'sqlite']); + $this->loadMigrationsFrom(__DIR__.'/database/migrations'); + $this->withFactories(__DIR__.'/database/factories'); - $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + $this->registerPromiseResolver(); - $this->configurePubSub(); + $this->registerManagers(); + + $this->registerStatisticsCollectors(); + + $this->registerStatisticsStores(); + + $this->pusherServer = $this->app->make(config('websockets.handlers.websocket')); + + if ($this->replicationMode === 'redis') { + $this->registerRedis(); + } + + if (method_exists($this->channelManager, 'getPublishClient')) { + $this->getPublishClient()->resetAssertions(); + $this->getSubscribeClient()->resetAssertions(); + } } /** @@ -59,14 +105,69 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase { return [ \BeyondCode\LaravelWebSockets\WebSocketsServiceProvider::class, + TestServiceProvider::class, ]; } /** * {@inheritdoc} */ - protected function getEnvironmentSetUp($app) + public function getEnvironmentSetUp($app) { + $this->replicationMode = getenv('REPLICATION_MODE') ?: 'local'; + + $app['config']->set('database.default', 'sqlite'); + + $app['config']->set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => __DIR__.'/database.sqlite', + '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('queue.default', 'async-redis'); + + $app['config']->set('queue.connections.async-redis', [ + 'driver' => 'async-redis', + 'connection' => env('WEBSOCKETS_REDIS_REPLICATION_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => 90, + 'block_for' => null, + ]); + + $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', @@ -91,52 +192,127 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase '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' => 'websockets', - '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', 'websockets'); - } } /** - * Get the websocket connection for a specific URL. + * Register the test promise resolver. * - * @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 registerPromiseResolver() { - $connection = new Connection; + Helpers::$loop = $this->loop; + + $this->app['config']->set( + 'websockets.promise_resolver', + \BeyondCode\LaravelWebSockets\Test\Mocks\PromiseResolver::class + ); + } + + /** + * Register the managers that are not resolved + * by the package service provider. + * + * @return void + */ + protected function registerManagers() + { + $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); + } + + /** + * Unregister the managers for testing purposes. + * + * @return void + */ + protected function unregisterManagers() + { + $this->app->offsetUnset(ChannelManager::class); + } + + /** + * Register the statistics collectors. + * + * @return void + */ + protected function registerStatisticsCollectors() + { + $this->statisticsCollector = $this->app->make(StatisticsCollector::class); + + $this->artisan('websockets: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); @@ -149,23 +325,21 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase * @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(json_encode([ + $message = new Mocks\Message([ 'event' => 'pusher:subscribe', 'data' => [ 'channel' => $channel, ], - ])); + ]); $this->pusherServer->onMessage($connection, $message); } @@ -177,31 +351,31 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase * Join a presence channel. * * @param string $channel - * @return \BeyondCode\LaravelWebSockets\Tests\Mocks\Connection + * @param array $user + * @param string $appKey + * @param array $headers + * @return Mocks\Connection */ - protected function joinPresenceChannel($channel): Connection + protected function newPresenceConnection($channel, array $user = [], string $appKey = 'TestKey', array $headers = []) { - $connection = $this->getWebSocketConnection(); + $connection = $this->newConnection($appKey, $headers); $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); + $encodedUser = json_encode($user); - $message = new Message(json_encode([ + $message = new Mocks\SignedMessage([ 'event' => 'pusher:subscribe', 'data' => [ - 'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret), 'channel' => $channel, - 'channel_data' => json_encode($channelData), + 'channel_data' => $encodedUser, ], - ])); + ], $connection, $channel, $encodedUser); $this->pusherServer->onMessage($connection, $message); @@ -209,90 +383,86 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase } /** - * 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 + * @param string $appKey + * @param array $headers + * @return Mocks\Connection */ - protected function getChannel(ConnectionInterface $connection, string $channelName) + protected function newPrivateConnection($channel, string $appKey = 'TestKey', array $headers = []) { - return $this->channelManager->findOrCreate($connection->app->id, $channelName); - } + $connection = $this->newConnection($appKey, $headers); - /** - * Configure the replicator clients. - * - * @return void - */ - protected function configurePubSub() - { - // Replace the publish and subscribe clients with a Mocked - // factory lazy instance on boot. - if (config('websockets.replication.driver') === 'redis') { - $this->app->singleton(ReplicationInterface::class, function () { - return (new RedisClient)->boot( - LoopFactory::create(), Mocks\RedisFactory::class - ); - }); - } + $this->pusherServer->onOpen($connection); - if (config('websockets.replication.driver') === 'local') { - $this->app->singleton(ReplicationInterface::class, function () { - return new LocalClient; - }); - } - } + $message = new Mocks\SignedMessage([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'channel' => $channel, + ], + ], $connection, $channel); - protected function runOnlyOnRedisReplication() - { - if (config('websockets.replication.driver') !== 'redis') { - $this->markTestSkipped('Skipped test because the replication driver is not set to Redis.'); - } - } + $this->pusherServer->onMessage($connection, $message); - 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(); + } + + /** + * Reset the database. + * + * @return void + */ + protected function resetDatabase() + { + 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 new file mode 100644 index 0000000..c43ce45 --- /dev/null +++ b/tests/TestServiceProvider.php @@ -0,0 +1,31 @@ +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); + } +} diff --git a/tests/database/factories/UserFactory.php b/tests/database/factories/UserFactory.php new file mode 100644 index 0000000..07d6e7b --- /dev/null +++ b/tests/database/factories/UserFactory.php @@ -0,0 +1,22 @@ +define(\BeyondCode\LaravelWebSockets\Test\Models\User::class, function () { + return [ + 'name' => 'Name'.Str::random(5), + 'email' => Str::random(5).'@gmail.com', + 'password' => '$2y$10$TKh8H1.PfQx37YgCzwiKb.KjNyWgaHb9cbcoQgdIVFlYg7B77UdFm', // secret + 'remember_token' => Str::random(10), + ]; +}); diff --git a/tests/database/migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php b/tests/database/migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php new file mode 100644 index 0000000..0989f28 --- /dev/null +++ b/tests/database/migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php @@ -0,0 +1,35 @@ +increments('id'); + $table->string('app_id'); + $table->integer('peak_connections_count'); + $table->integer('websocket_messages_count'); + $table->integer('api_messages_count'); + $table->nullableTimestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('websockets_statistics_entries'); + } +}