diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000000..33dbc6bf33 --- /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 cd8eb86efa..9718070fd3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,5 +11,8 @@ end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true +[*.{blade.php,yml,yaml}] +indent_size = 2 + [*.md] trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..9a6dfff364 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +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.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 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 + coverage: pcov + + - 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 vendor/bin/phpunit --coverage-text --coverage-clover=coverage_local.xml + + - name: Run tests for Redis + run: | + REPLICATION_MODE=redis vendor/bin/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 dce4f69827..0000000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,55 +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.*, 8.*] - dependency-version: [prefer-lowest, prefer-stable] - include: - - laravel: 8.* - testbench: 6.* - - laravel: 7.* - testbench: 5.* - - laravel: 6.* - testbench: 4.* - exclude: - - php: 7.2 - laravel: 8.* - - name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} - - steps: - - name: Checkout code - uses: actions/checkout@v1 - - - 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: curl, dom, libxml, mbstring, pdo, sqlite, pdo_sqlite, zip - coverage: pcov - - - 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 - run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage.xml - - - uses: codecov/codecov-action@v1 - with: - fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index 461ba139fd..65e1146246 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ +/vendor +/.idea build -composer.lock -vendor +.phpunit.result.cache coverage -.phpunit.result.cache \ No newline at end of file +composer.phar +composer.lock +.DS_Store +database.sqlite diff --git a/.scrutinizer.yml b/.scrutinizer.yml index df16b68b52..76733d0c94 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 f4d3cbc61b..c3bb259c55 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 a3341a87c7..0000000000 --- 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 578e96447d..0038b4c338 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,48 +21,58 @@ "email": "freek@spatie.be", "homepage": "https://spatie.be", "role": "Developer" + }, + { + "name": "Alex Renoki", + "homepage": "https://github.com/rennokki", + "role": "Developer" } ], "require": { - "php": "^7.2", - "ext-json": "*", "cboden/ratchet": "^0.4.1", + "clue/redis-react": "^2.3", + "doctrine/dbal": "^2.9", + "evenement/evenement": "^2.0|^3.0", "facade/ignition-contracts": "^1.0", "guzzlehttp/psr7": "^1.5", - "illuminate/broadcasting": "^6.0|^7.0|^8.0", - "illuminate/console": "^6.0|^7.0|^8.0", - "illuminate/http": "^6.0|^7.0|^8.0", - "illuminate/routing": "^6.0|^7.0|^8.0", - "illuminate/support": "^6.0|^7.0|^8.0", - "pusher/pusher-php-server": "^3.0|^4.0", - "react/dns": "^1.1", - "react/http": "^1.1", + "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": "^4.0", + "react/promise": "^2.0", "symfony/http-kernel": "^4.0|^5.0", "symfony/psr-http-message-bridge": "^1.1|^2.0" }, "require-dev": { - "mockery/mockery": "^1.3", - "orchestra/testbench": "^4.0|^5.0|^6.0", + "clue/block-react": "^1.4", + "laravel/legacy-factories": "^1.1", + "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 45415d76c5..681bb6bdc1 100644 --- a/config/websockets.php +++ b/config/websockets.php @@ -1,141 +1,299 @@ [ + 'port' => env('LARAVEL_WEBSOCKETS_PORT', 6001), + + 'domain' => env('LARAVEL_WEBSOCKETS_DOMAIN'), + + 'path' => env('LARAVEL_WEBSOCKETS_PATH', 'laravel-websockets'), + + 'middleware' => [ + 'web', + \BeyondCode\LaravelWebSockets\Dashboard\Http\Middleware\Authorize::class, + ], + + ], + + 'managers' => [ + + /* + |-------------------------------------------------------------------------- + | Application Manager + |-------------------------------------------------------------------------- + | + | An Application manager determines how your websocket server allows + | the use of the TCP protocol based on, for example, a list of allowed + | applications. + | By default, it uses the defined array in the config file, but you can + | anytime implement the same interface as the class and add your own + | custom method to retrieve the apps. + | + */ + + 'app' => \BeyondCode\LaravelWebSockets\Apps\ConfigAppManager::class, + ], /* - * This package comes with multi tenancy out of the box. Here you can - * configure the different apps that can use the webSockets server. - * - * Optionally you specify capacity so you can limit the maximum - * concurrent connections for a specific app. - * - * Optionally you can disable client events so clients cannot send - * messages to each other via the webSockets. - */ + |-------------------------------------------------------------------------- + | Applications Repository + |-------------------------------------------------------------------------- + | + | By default, the only allowed app is the one you define with + | your PUSHER_* variables from .env. + | You can configure to use multiple apps if you need to, or use + | a custom App Manager that will handle the apps from a database, per se. + | + | You can apply multiple settings, like the maximum capacity, enable + | client-to-client messages or statistics. + | + */ + 'apps' => [ [ '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'), 'capacity' => null, 'enable_client_messages' => false, 'enable_statistics' => true, + 'allowed_origins' => [ + // env('LARAVEL_WEBSOCKETS_DOMAIN'), + ], ], ], /* - * This class is responsible for finding the apps. The default provider - * will use the apps defined in this config file. - * - * You can create a custom provider by implementing the - * `AppProvider` interface. - */ - 'app_provider' => BeyondCode\LaravelWebSockets\Apps\ConfigAppProvider::class, + |-------------------------------------------------------------------------- + | Broadcasting Replication PubSub + |-------------------------------------------------------------------------- + | + | You can enable replication to publish and subscribe to + | messages across the driver. + | + | By default, it is set to 'local', but you can configure it to use drivers + | like Redis to ensure connection between multiple instances of + | WebSocket servers. Just set the driver to 'redis' to enable the PubSub using Redis. + | + */ - /* - * This array contains the hosts of which you want to allow incoming requests. - * Leave this empty if you want to accept requests from all hosts. - */ - 'allowed_origins' => [ - // - ], + 'replication' => [ - /* - * The maximum request size in kilobytes that is allowed for an incoming WebSocket request. - */ - 'max_request_size_in_kb' => 250, + 'mode' => env('WEBSOCKETS_REPLICATION_MODE', 'local'), - /* - * This path will be used to register the necessary routes for the package. - */ - 'path' => 'laravel-websockets', + '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 members 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 members 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, + + ], + + ], - /* - * Dashboard Routes Middleware - * - * These middleware will be assigned to every dashboard route, giving you - * the chance to add your own middleware to this list or change any of - * the existing middleware. Or, you can simply stick with this list. - */ - 'middleware' => [ - 'web', - Authorize::class, ], 'statistics' => [ + /* - * This model will be used to store the statistics of the WebSocketsServer. - * The only requirement is that the model should extend - * `WebSocketsStatisticsEntry` provided by this package. - */ - 'model' => \BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry::class, - - /** - * The Statistics Logger will, by default, handle the incoming statistics, store them - * and then release them into the database on each interval defined below. - */ - 'logger' => BeyondCode\LaravelWebSockets\Statistics\Logger\HttpStatisticsLogger::class, + |-------------------------------------------------------------------------- + | 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, /* - * Here you can specify the interval in seconds at which statistics should be logged. - */ + |-------------------------------------------------------------------------- + | Statistics Interval Period + |-------------------------------------------------------------------------- + | + | Here you can specify the interval in seconds at which + | statistics should be logged. + | + */ + 'interval_in_seconds' => 60, /* - * When the clean-command is executed, all recorded statistics older than - * the number of days specified here will be deleted. - */ + |-------------------------------------------------------------------------- + | 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, - /* - * Use an DNS resolver to make the requests to the statistics logger - * default is to resolve everything to 127.0.0.1. - */ - 'perform_dns_lookup' => false, ], /* - * Define the optional SSL context for your WebSocket connections. - * You can see all available options at: http://php.net/manual/en/context.ssl.php - */ + |-------------------------------------------------------------------------- + | Maximum Request Size + |-------------------------------------------------------------------------- + | + | The maximum request size in kilobytes that is allowed for + | an incoming WebSocket request. + | + */ + + 'max_request_size_in_kb' => 250, + + /* + |-------------------------------------------------------------------------- + | SSL Configuration + |-------------------------------------------------------------------------- + | + | By default, the configuration allows only on HTTP. For SSL, you need + | to set up the the certificate, the key, and optionally, the passphrase + | for the private key. + | You will need to restart the server for the settings to take place. + | + */ + 'ssl' => [ - /* - * Path to local certificate file on filesystem. It must be a PEM encoded file which - * contains your certificate and private key. It can optionally contain the - * certificate chain of issuers. The private key also may be contained - * in a separate file specified by local_pk. - */ + 'local_cert' => env('LARAVEL_WEBSOCKETS_SSL_LOCAL_CERT', null), - /* - * Path to local private key file on filesystem in case of separate files for - * certificate (local_cert) and private key. - */ + 'capath' => env('LARAVEL_WEBSOCKETS_SSL_CA', null), + 'local_pk' => env('LARAVEL_WEBSOCKETS_SSL_LOCAL_PK', null), - /* - * Passphrase for your local_cert file. - */ 'passphrase' => env('LARAVEL_WEBSOCKETS_SSL_PASSPHRASE', null), + + 'verify_peer' => env('APP_ENV') === 'production', + + 'allow_self_signed' => env('APP_ENV') !== 'production', + ], /* - * Channel Manager - * This class handles how channel persistence is handled. - * By default, persistence is stored in an array by the running webserver. - * The only requirement is that the class should implement - * `ChannelManager` interface provided by this package. - */ - 'channel_manager' => \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\ArrayChannelManager::class, + |-------------------------------------------------------------------------- + | Route Handlers + |-------------------------------------------------------------------------- + | + | Here you can specify the route handlers that will take over + | the incoming/outgoing websocket connections. You can extend the + | original class and implement your own logic, alongside + | with the existing logic. + | + */ + + 'handlers' => [ + + '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, + + ], + + /* + |-------------------------------------------------------------------------- + | Promise Resolver + |-------------------------------------------------------------------------- + | + | 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. + | + */ + + '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 0000000000..70dbf79acd --- /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 183f7e60d0..7c504e5514 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 c0c92ecaf7..77f4502144 100644 --- a/docs/advanced-usage/app-providers.md +++ b/docs/advanced-usage/app-providers.md @@ -1,69 +1,74 @@ -# Custom App Providers +--- +title: Custom App Managers +order: 1 +--- + +# Custom App Managers With the multi-tenancy support of Laravel WebSockets, the default way of storing and retrieving the apps is by using the `websockets.php` config file. -Depending on your setup, you might have your app configuration stored elsewhere and having to keep the configuration in sync with your app storage can be tedious. To simplify this, you can create your own `AppProvider` class that will take care of retrieving the WebSocket credentials for a specific WebSocket application. +Depending on your setup, you might have your app configuration stored elsewhere and having to keep the configuration in sync with your app storage can be tedious. To simplify this, you can create your own `AppManager` class that will take care of retrieving the WebSocket credentials for a specific WebSocket application. -> Make sure that you do **not** perform any IO blocking tasks in your `AppProvider`, as they will interfere with the asynchronous WebSocket execution. +> 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 `AppProvider`, create a class that implements the `BeyondCode\LaravelWebSockets\AppProviders\AppProvider` 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: ```php -interface AppProvider +interface AppManager { - /** @return array[BeyondCode\LaravelWebSockets\AppProviders\App] */ + /** @return array[BeyondCode\LaravelWebSockets\Apps\App] */ public function all(): array; - /** @return BeyondCode\LaravelWebSockets\AppProviders\App */ + /** @return BeyondCode\LaravelWebSockets\Apps\App */ public function findById($appId): ?App; - /** @return BeyondCode\LaravelWebSockets\AppProviders\App */ - public function findByKey(string $appKey): ?App; + /** @return BeyondCode\LaravelWebSockets\Apps\App */ + public function findByKey($appKey): ?App; - /** @return BeyondCode\LaravelWebSockets\AppProviders\App */ - public function findBySecret(string $appSecret): ?App; + /** @return BeyondCode\LaravelWebSockets\Apps\App */ + public function findBySecret($appSecret): ?App; } ``` -The following is an example AppProvider that utilizes an Eloquent model: +The following is an example AppManager that utilizes an Eloquent model: ```php -namespace App\Providers; +namespace App\Managers; use App\Application; use BeyondCode\LaravelWebSockets\Apps\App; -use BeyondCode\LaravelWebSockets\Apps\AppProvider; +use BeyondCode\LaravelWebSockets\Contracts\AppManager; -class MyCustomAppProvider implements AppProvider +class MyCustomAppManager implements AppManager { public function all() : array { return Application::all() ->map(function($app) { - return $this->instanciate($app->toArray()); + return $this->normalize($app->toArray()); }) ->toArray(); } - public function findById($appId) : ? App + public function findById($appId) : ?App { - return $this->instanciate(Application::findById($appId)->toArray()); + return $this->normalize(Application::findById($appId)->toArray()); } - public function findByKey(string $appKey) : ? App + public function findByKey($appKey) : ?App { - return $this->instanciate(Application::findByKey($appKey)->toArray()); + return $this->normalize(Application::findByKey($appKey)->toArray()); } - public function findBySecret(string $appSecret) : ? App + public function findBySecret($appSecret) : ?App { - return $this->instanciate(Application::findBySecret($appSecret)->toArray()); + return $this->normalize(Application::findBySecret($appSecret)->toArray()); } - protected function instanciate(?array $appAttributes) : ? App + protected function normalize(?array $appAttributes) : ?App { - if (!$appAttributes) { + if (! $appAttributes) { return null; } @@ -90,15 +95,26 @@ class MyCustomAppProvider implements AppProvider } ``` -Once you have implemented your own AppProvider, you need to set it in the `websockets.php` configuration file: - -```php -/** - * This class is responsible for finding the apps. The default provider - * will use the apps defined in this config file. - * - * You can create a custom provider by implementing the - * `AppProvider` interface. - */ -'app_provider' => MyCustomAppProvider::class, +Once you have implemented your own AppManager, you need to set it in the `websockets.php` configuration file: + +```php +'managers' => [ + + /* + |-------------------------------------------------------------------------- + | Application Manager + |-------------------------------------------------------------------------- + | + | An Application manager determines how your websocket server allows + | the use of the TCP protocol based on, for example, a list of allowed + | applications. + | By default, it uses the defined array in the config file, but you can + | anytime implement the same interface as the class and add your own + | custom method to retrieve the apps. + | + */ + + 'app' => \App\Managers\MyCustomAppManager::class, + +], ``` diff --git a/docs/advanced-usage/custom-websocket-handlers.md b/docs/advanced-usage/custom-websocket-handlers.md index 77d4eb9394..71ebe60c81 100644 --- a/docs/advanced-usage/custom-websocket-handlers.md +++ b/docs/advanced-usage/custom-websocket-handlers.md @@ -1,6 +1,11 @@ +--- +title: Custom WebSocket Handlers +order: 2 +--- + # Custom WebSocket Handlers -While this package's main purpose is to make the usage of either the Pusher JavaScript client or Laravel Echo as easy as possible, you are not limited to the Pusher protocol at all. +While this package's main purpose is to make the usage of either the Pusher JavaScript client or Laravel Echo as easy as possible, you are not limited to the Pusher protocol at all. There might be situations where all you need is a simple, bare-bone, WebSocket server where you want to have full control over the incoming payload and what you want to do with it - without having "channels" in the way. You can easily create your own custom WebSocketHandler class. All you need to do is implement Ratchets `Ratchet\WebSocket\MessageComponentInterface`. @@ -10,24 +15,24 @@ 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. } - + public function onClose(ConnectionInterface $connection) { // TODO: Implement onClose() method. } - public function onError(ConnectionInterface $connection, \Exception $e) + public function onError(ConnectionInterface $connection, Exception $e) { // TODO: Implement onError() method. } @@ -43,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. \ No newline at end of file +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 0000000000..be5e095b24 --- /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 0000000000..98ed10d1a8 --- /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 new file mode 100644 index 0000000000..2df8e928ec --- /dev/null +++ b/docs/advanced-usage/webhooks.md @@ -0,0 +1,53 @@ +--- +title: Webhooks +order: 3 +--- + +# Webhooks + +While you can create any custom websocket handlers, you might still want to intercept and run your own custom business logic on each websocket connection. + +In Pusher, there are [Pusher Webhooks](https://pusher.com/docs/channels/server_api/webhooks) that do this job. However, since the implementation is a pure controller, +you might want to extend it and update the config file to reflect the changes: + +For example, running your own business logic on connection open and close: + +```php +namespace App\Controllers\WebSockets; + +use BeyondCode\LaravelWebSockets\WebSockets\WebSocketHandler as BaseWebSocketHandler; +use Ratchet\ConnectionInterface; + +class WebSocketHandler extends BaseWebSocketHandler +{ + public function onOpen(ConnectionInterface $connection) + { + parent::onOpen($connection); + + // Run code on open + // $connection->app contains the app details + // $this->channelManager is accessible + } + + public function onClose(ConnectionInterface $connection) + { + parent::onClose($connection); + + // Run code on close. + // $connection->app contains the app details + // $this->channelManager is accessible + } +} +``` + +Once you implemented it, replace the `handlers.websocket` class name in config: + +```php +'handlers' => [ + + 'websocket' => App\Controllers\WebSockets\WebSocketHandler::class, + +], +``` + +A server restart is required afterwards. diff --git a/docs/basic-usage/pusher.md b/docs/basic-usage/pusher.md index 7c07e034e2..6d72a2d549 100644 --- a/docs/basic-usage/pusher.md +++ b/docs/basic-usage/pusher.md @@ -7,14 +7,16 @@ order: 1 The easiest way to get started with Laravel WebSockets is by using it as a [Pusher](https://pusher.com) replacement. The integrated WebSocket and HTTP Server has complete feature parity with the Pusher WebSocket and HTTP API. In addition to that, this package also ships with an easy to use debugging dashboard to see all incoming and outgoing WebSocket requests. +To make it clear, the package does not restrict connections numbers or depend on the Pusher's service. It does comply with the Pusher protocol to make it easy to use the Pusher SDK with it. + ## Requirements -To make use of the Laravel WebSockets package in combination with Pusher, you first need to install the official Pusher PHP SDK. +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 "~3.0" +composer require pusher/pusher-php-server "~4.0" ``` Next, you should make sure to use Pusher as your broadcasting driver. This can be achieved by setting the `BROADCAST_DRIVER` environment variable in your `.env` file: @@ -38,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, + ], ], ], ``` @@ -68,8 +74,11 @@ You may add additional apps in your `config/websockets.php` file. 'name' => env('APP_NAME'), 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), + 'path' => env('PUSHER_APP_PATH'), + 'capacity' => null, 'enable_client_messages' => false, 'enable_statistics' => true, + 'allowed_origins' => [], ], ], ``` @@ -90,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. @@ -102,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'); @@ -113,7 +122,8 @@ window.Echo = new Echo({ wsPort: 6001, forceTLS: false, disableStats: true, + enabledTransports: ['ws', 'wss'], }); ``` -Now you can use all Laravel Echo features in combination with Laravel WebSockets, such as [Presence Channels](https://laravel.com/docs/6.0/broadcasting#presence-channels), [Notifications](https://laravel.com/docs/6.0/broadcasting#notifications) and [Client Events](https://laravel.com/docs/6.0/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 new file mode 100644 index 0000000000..56c5539289 --- /dev/null +++ b/docs/basic-usage/restarting.md @@ -0,0 +1,14 @@ +--- +title: Restarting Server +order: 4 +--- + +# Restarting Server + +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 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 7e700d8459..33092435d5 100644 --- a/docs/basic-usage/ssl.md +++ b/docs/basic-usage/ssl.md @@ -10,32 +10,29 @@ 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 'ssl' => [ - /* - * Path to local certificate file on filesystem. It must be a PEM encoded file which - * contains your certificate and private key. It can optionally contain the - * certificate chain of issuers. The private key also may be contained - * in a separate file specified by local_pk. - */ - 'local_cert' => null, - - /* - * Path to local private key file on filesystem in case of separate files for - * certificate (local_cert) and private key. - */ - 'local_pk' => null, - - /* - * Passphrase with which your local_cert file was encoded. - */ - 'passphrase' => null + + 'local_cert' => env('LARAVEL_WEBSOCKETS_SSL_LOCAL_CERT', null), + + 'capath' => env('LARAVEL_WEBSOCKETS_SSL_CA', null), + + 'local_pk' => env('LARAVEL_WEBSOCKETS_SSL_LOCAL_PK', null), + + 'passphrase' => env('LARAVEL_WEBSOCKETS_SSL_PASSPHRASE', null), + + 'verify_peer' => env('APP_ENV') === 'production', + + 'allow_self_signed' => env('APP_ENV') !== 'production', + ], ``` 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. @@ -62,13 +59,20 @@ window.Echo = new Echo({ wsHost: window.location.hostname, wsPort: 6001, disableStats: true, - forceTLS: true + forceTLS: true, + enabledTransports: ['ws', 'wss'], }); ``` ## 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' => [ @@ -78,9 +82,10 @@ When broadcasting events from your Laravel application to the WebSocket server, 'app_id' => env('PUSHER_APP_ID'), 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), - 'host' => '127.0.0.1', - 'port' => 6001, - 'scheme' => 'https' + 'encrypted' => true, + 'host' => env('PUSHER_APP_HOST', '127.0.0.1'), + 'port' => env('PUSHER_APP_PORT', 6001), + 'scheme' => env('PUSHER_APP_SCHEME', 'http'), ], ], ``` @@ -98,26 +103,19 @@ Make sure that you replace `YOUR-USERNAME` with your Mac username and `VALET-SIT ```php 'ssl' => [ - /* - * Path to local certificate file on filesystem. It must be a PEM encoded file which - * contains your certificate and private key. It can optionally contain the - * certificate chain of issuers. The private key also may be contained - * in a separate file specified by local_pk. - */ + 'local_cert' => '/Users/YOUR-USERNAME/.config/valet/Certificates/VALET-SITE.TLD.crt', - /* - * Path to local private key file on filesystem in case of separate files for - * certificate (local_cert) and private key. - */ + 'capath' => env('LARAVEL_WEBSOCKETS_SSL_CA', null), + 'local_pk' => '/Users/YOUR-USERNAME/.config/valet/Certificates/VALET-SITE.TLD.key', - /* - * Passphrase with which your local_cert file was encoded. - */ - 'passphrase' => null, + 'passphrase' => env('LARAVEL_WEBSOCKETS_SSL_PASSPHRASE', null), + + 'verify_peer' => env('APP_ENV') === 'production', + + 'allow_self_signed' => env('APP_ENV') !== 'production', - 'verify_peer' => false, ], ``` @@ -133,13 +131,14 @@ You also need to disable SSL verification. 'app_id' => env('PUSHER_APP_ID'), 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), - 'host' => '127.0.0.1', - 'port' => 6001, - 'scheme' => 'https', + 'encrypted' => true, + '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, - ] + ], ], ], ``` @@ -208,7 +207,7 @@ server { location / { try_files /nonexistent @$type; } - + location @web { try_files $uri $uri/ /index.php?$query_string; } @@ -273,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} - } - - proxy /websocket-proxy 127.0.0.1:6001 { - without /special-websocket-url - transparent - websocket + @ws { + header Connection *Upgrade* + header Upgrade 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/basic-usage/starting.md b/docs/basic-usage/starting.md index 8892468f74..6b28439778 100644 --- a/docs/basic-usage/starting.md +++ b/docs/basic-usage/starting.md @@ -30,51 +30,3 @@ For example, by using `127.0.0.1`, you will only allow WebSocket connections fro ```bash php artisan websockets:serve --host=127.0.0.1 ``` - -## Keeping the socket server running with supervisord - -The `websockets:serve` daemon needs to always be running in order to accept connections. This is a prime use case for `supervisor`, a task runner on Linux. - -First, make sure `supervisor` is installed. - -```bash -# On Debian / Ubuntu -apt install supervisor - -# On Red Hat / CentOS -yum install supervisor -systemctl enable supervisord -``` - -Once installed, add a new process that `supervisor` needs to keep running. You place your configurations in the `/etc/supervisor/conf.d` (Debian/Ubuntu) or `/etc/supervisord.d` (Red Hat/CentOS) directory. - -Within that directory, create a new file called `websockets.conf`. - -```bash -[program:websockets] -command=/usr/bin/php /home/laravel-echo/laravel-websockets/artisan websockets:serve -numprocs=1 -autostart=true -autorestart=true -user=laravel-echo -``` - -Once created, instruct `supervisor` to reload its configuration files (without impacting the already running `supervisor` jobs). - -```bash -supervisorctl update -supervisorctl start websockets -``` - -Your echo server should now be running (you can verify this with `supervisorctl status`). If it were to crash, `supervisor` will automatically restart it. - -Please note that, by default, `supervisor` will force a maximum number of open files onto all the processes that it manages. This is configured by the `minfds` parameter in `supervisord.conf`. - -If you want to increase the maximum number of open files, you may do so in `/etc/supervisor/supervisord.conf` (Debian/Ubuntu) or `/etc/supervisord.conf` (Red Hat/CentOS): - -``` -[supervisord] -minfds=10240; (min. avail startup file descriptors;default 1024) -``` - -After changing this setting, you'll need to restart the supervisor process (which in turn will restart all your processes that it manages). diff --git a/docs/debugging/console.md b/docs/debugging/console.md index 4ff9013ac8..cf0e97a652 100644 --- a/docs/debugging/console.md +++ b/docs/debugging/console.md @@ -7,4 +7,6 @@ order: 1 When you start the Laravel WebSocket server and your application is in debug mode, you will automatically see all incoming and outgoing WebSocket events in your terminal. -![Console Logging](/img/console.png) \ No newline at end of file +On production environments, you shall use the `--debug` flag to display the events in the terminal. + +![Console Logging](/img/console.png) diff --git a/docs/debugging/dashboard.md b/docs/debugging/dashboard.md index af54bf9886..bba0551bf7 100644 --- a/docs/debugging/dashboard.md +++ b/docs/debugging/dashboard.md @@ -14,7 +14,7 @@ In addition to logging the events to the console, you can also use a real-time d The default location of the WebSocket dashboard is at `/laravel-websockets`. The routes get automatically registered. If you want to change the URL of the dashboard, you can configure it with the `path` setting in your `config/websockets.php` file. -To access the debug dashboard, you can visit the dashboard URL of your Laravel project in the browser. +To access the debug dashboard, you can visit the dashboard URL of your Laravel project in the browser Since your WebSocket server has support for multiple apps, you can select which app you want to connect to and inspect. By pressing the "Connect" button, you can establish the WebSocket connection and see all events taking place on your WebSocket server from there on in real-time. @@ -67,6 +67,16 @@ protected function schedule(Schedule $schedule) } ``` +## Disable Statistics + +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 call `--disable-statistics` when running the server command: + +```bash +php artisan websockets:serve --disable-statistics +``` + ## 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/_index.md b/docs/faq/_index.md index 688d140733..47b9d3aa0f 100644 --- a/docs/faq/_index.md +++ b/docs/faq/_index.md @@ -1,4 +1,4 @@ --- title: FAQ -order: 5 +order: 6 --- diff --git a/docs/faq/cloudflare.md b/docs/faq/cloudflare.md new file mode 100644 index 0000000000..d5a933a9ea --- /dev/null +++ b/docs/faq/cloudflare.md @@ -0,0 +1,18 @@ +--- +title: Cloudflare +order: 3 +--- + +# Cloudflare + +In some cases, you might use Cloudflare and notice that your production server does not seem to respond to your `:6001` port. + +This is because Cloudflare does not seem to open ports, [excepting a few of them](https://blog.cloudflare.com/cloudflare-now-supporting-more-ports/). + +To mitigate this issue, for example, you can run your server on port `2096`: + +```bash +php artisan websockets:serve --port=2096 +``` + +You will notice that the new `:2096` websockets server will work properly. diff --git a/docs/faq/deploying.md b/docs/faq/deploying.md index 7ed276758d..8cca2dc58d 100644 --- a/docs/faq/deploying.md +++ b/docs/faq/deploying.md @@ -46,3 +46,61 @@ sudo pecl install event #### Deploying on Laravel Forge If your are using [Laravel Forge](https://forge.laravel.com/) for the deployment [this article by Alex Bouma](https://alex.bouma.dev/installing-laravel-websockets-on-forge) might help you out. + +#### Deploying on Laravel Vapor + +Since [Laravel Vapor](https://vapor.laravel.com) runs on a serverless architecture, you will need to spin up an actual EC2 Instance that runs in the same VPC as the Lambda function to be able to make use of the WebSocket connection. + +The Lambda function will make sure your HTTP request gets fulfilled, then the EC2 Instance will be continuously polled through the WebSocket protocol. + +## Keeping the socket server running with supervisord + +The `websockets:serve` daemon needs to always be running in order to accept connections. This is a prime use case for `supervisor`, a task runner on Linux. + +First, make sure `supervisor` is installed. + +```bash +# On Debian / Ubuntu +apt install supervisor + +# On Red Hat / CentOS +yum install supervisor +systemctl enable supervisord +``` + +Once installed, add a new process that `supervisor` needs to keep running. You place your configurations in the `/etc/supervisor/conf.d` (Debian/Ubuntu) or `/etc/supervisord.d` (Red Hat/CentOS) directory. + +Within that directory, create a new file called `websockets.conf`. + +```bash +[program:websockets] +command=/usr/bin/php /home/laravel-echo/laravel-websockets/artisan websockets:serve +numprocs=1 +autostart=true +autorestart=true +user=laravel-echo +``` + +Once created, instruct `supervisor` to reload its configuration files (without impacting the already running `supervisor` jobs). + +```bash +supervisorctl update +supervisorctl start websockets +``` + +Your echo server should now be running (you can verify this with `supervisorctl status`). If it were to crash, `supervisor` will automatically restart it. + +Please note that, by default, just like file descriptiors, `supervisor` will force a maximum number of open files onto all the processes that it manages. This is configured by the `minfds` parameter in `supervisord.conf`. + +If you want to increase the maximum number of open files, you may do so in `/etc/supervisor/supervisord.conf` (Debian/Ubuntu) or `/etc/supervisord.conf` (Red Hat/CentOS): + +``` +[supervisord] +minfds=10240; (min. avail startup file descriptors;default 1024) +``` + +After changing this setting, you'll need to restart the supervisor process (which in turn will restart all your processes that it manages). + +## Debugging supervisor + +If you run into issues with Supervisor, like not supporting a lot of connections, consider checking the [Ratched docs on deploying with Supervisor](http://socketo.me/docs/deploy#supervisor). diff --git a/docs/faq/scaling.md b/docs/faq/scaling.md index f3768d34db..b5033f0ec5 100644 --- a/docs/faq/scaling.md +++ b/docs/faq/scaling.md @@ -1,9 +1,9 @@ --- -title: ... but does it scale? +title: Benchmarks order: 2 --- -# ... but does it scale? +# Benchmarks Of course, this is not a question with an easy answer as your mileage may vary. But with the appropriate server-side configuration your WebSocket server can easily hold a **lot** of concurrent connections. @@ -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 0eddbd0b68..5d24d7dd6a 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -13,162 +13,24 @@ composer require beyondcode/laravel-websockets The package will automatically register a service provider. -This package comes with a migration to store statistic information while running your WebSocket server. You can publish the migration file using: +You need to publish the WebSocket configuration file: ```bash -php artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsServiceProvider" --tag="migrations" +php artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsServiceProvider" --tag="config" ``` -Run the migrations with: +# Statistics -```bash -php artisan migrate -``` +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. -Next, you need to publish the WebSocket configuration file: +You can publish the migration file using: ```bash -php artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsServiceProvider" --tag="config" +php artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsServiceProvider" --tag="migrations" ``` -This is the default content of the config file that will be published as `config/websockets.php`: - -```php -return [ - - /* - * Set a custom dashboard configuration - */ - 'dashboard' => [ - 'port' => env('LARAVEL_WEBSOCKETS_PORT', 6001), - ], - - /* - * This package comes with multi tenancy out of the box. Here you can - * configure the different apps that can use the webSockets server. - * - * Optionally you specify capacity so you can limit the maximum - * concurrent connections for a specific app. - * - * Optionally you can disable client events so clients cannot send - * messages to each other via the webSockets. - */ - 'apps' => [ - [ - 'id' => env('PUSHER_APP_ID'), - 'name' => env('APP_NAME'), - 'key' => env('PUSHER_APP_KEY'), - 'secret' => env('PUSHER_APP_SECRET'), - 'path' => env('PUSHER_APP_PATH'), - 'capacity' => null, - 'enable_client_messages' => false, - 'enable_statistics' => true, - ], - ], - - /* - * This class is responsible for finding the apps. The default provider - * will use the apps defined in this config file. - * - * You can create a custom provider by implementing the - * `AppProvider` interface. - */ - 'app_provider' => BeyondCode\LaravelWebSockets\Apps\ConfigAppProvider::class, - - /* - * This array contains the hosts of which you want to allow incoming requests. - * Leave this empty if you want to accept requests from all hosts. - */ - 'allowed_origins' => [ - // - ], - - /* - * The maximum request size in kilobytes that is allowed for an incoming WebSocket request. - */ - 'max_request_size_in_kb' => 250, - - /* - * This path will be used to register the necessary routes for the package. - */ - 'path' => 'laravel-websockets', - - /* - * Dashboard Routes Middleware - * - * These middleware will be assigned to every dashboard route, giving you - * the chance to add your own middleware to this list or change any of - * the existing middleware. Or, you can simply stick with this list. - */ - 'middleware' => [ - 'web', - Authorize::class, - ], - - 'statistics' => [ - /* - * This model will be used to store the statistics of the WebSocketsServer. - * The only requirement is that the model should extend - * `WebSocketsStatisticsEntry` provided by this package. - */ - 'model' => \BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry::class, - - /** - * The Statistics Logger will, by default, handle the incoming statistics, store them - * and then release them into the database on each interval defined below. - */ - 'logger' => BeyondCode\LaravelWebSockets\Statistics\Logger\HttpStatisticsLogger::class, - - /* - * Here you can specify the interval in seconds at which statistics should be logged. - */ - 'interval_in_seconds' => 60, - - /* - * 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, - - /* - * Use an DNS resolver to make the requests to the statistics logger - * default is to resolve everything to 127.0.0.1. - */ - 'perform_dns_lookup' => false, - ], - - /* - * Define the optional SSL context for your WebSocket connections. - * You can see all available options at: http://php.net/manual/en/context.ssl.php - */ - 'ssl' => [ - /* - * Path to local certificate file on filesystem. It must be a PEM encoded file which - * contains your certificate and private key. It can optionally contain the - * certificate chain of issuers. The private key also may be contained - * in a separate file specified by local_pk. - */ - 'local_cert' => env('LARAVEL_WEBSOCKETS_SSL_LOCAL_CERT', null), - - /* - * Path to local private key file on filesystem in case of separate files for - * certificate (local_cert) and private key. - */ - 'local_pk' => env('LARAVEL_WEBSOCKETS_SSL_LOCAL_PK', null), - - /* - * Passphrase for your local_cert file. - */ - 'passphrase' => env('LARAVEL_WEBSOCKETS_SSL_PASSPHRASE', null), - ], +Run the migrations with: - /* - * Channel Manager - * This class handles how channel persistence is handled. - * By default, persistence is stored in an array by the running webserver. - * The only requirement is that the class should implement - * `ChannelManager` interface provided by this package. - */ - 'channel_manager' => \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\ArrayChannelManager::class, -]; +```bash +php artisan migrate ``` diff --git a/docs/getting-started/introduction.md b/docs/getting-started/introduction.md index e061c8aa48..0e5050aee8 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/_index.md b/docs/horizontal-scaling/_index.md new file mode 100644 index 0000000000..f66c2b551e --- /dev/null +++ b/docs/horizontal-scaling/_index.md @@ -0,0 +1,4 @@ +--- +title: Horizontal Scaling +order: 5 +--- diff --git a/docs/horizontal-scaling/getting-started.md b/docs/horizontal-scaling/getting-started.md new file mode 100644 index 0000000000..1bb3ab42d8 --- /dev/null +++ b/docs/horizontal-scaling/getting-started.md @@ -0,0 +1,34 @@ +--- +title: Getting Started +order: 1 +--- + +When running Laravel WebSockets without additional configuration, you won't be able to scale your servers out. + +For example, even with Sticky Load Balancer settings, you won't be able to keep track of your users' connections to notify them properly when messages occur if you got multiple nodes that run the same `websockets:serve` command. + +The reason why this happen is because the default channel manager runs on arrays, which is not a database other instances can access. + +To do so, we need a database and a way of notifying other instances when connections occur. + +For example, Redis does a great job by encapsulating the both the way of notifying (Pub/Sub module) and the storage (key-value datastore). + +## Configure the replication + +To enable the replication, simply change the `replication.mode` name in the `websockets.php` file: + +```php +'replication' => [ + + '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) diff --git a/docs/horizontal-scaling/redis.md b/docs/horizontal-scaling/redis.md new file mode 100644 index 0000000000..4f6383583b --- /dev/null +++ b/docs/horizontal-scaling/redis.md @@ -0,0 +1,42 @@ +--- +title: Redis Mode +order: 2 +--- + +# Redis Mode + +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' => [ + + 'mode' => 'redis', + + ... + +], +``` + +You can set the connection name to the Redis database under `redis`: + +```php +'replication' => [ + + 'modes' => + + 'redis' => [ + + '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 179f0b3086..229ec35459 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 58a64261b9..9343967841 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -1,265 +1,452 @@ - - WebSockets Dashboard - - - - - + + + WebSockets Dashboard + + + + + + + + + + + - -
-
-
-
- - - - - - -
-
+ +
+
+
+ Connect to app +
+ +
+
+ +
+ +
+
+
+ + +
-
-
-

Realtime Statistics

-
-
-
-

Event Creator

-
-
-
- -
-
- -
-
-
-
-
- -
-
-
-
-
- -
-
-
-
-

Events

- - - - - - - - - - - - - - - - - -
TypeSocketDetailsTime
@{{ log.type }}@{{ log.socketId }}@{{ log.details }}@{{ log.time }}
+
+
+ +
+
+ + Live statistics + + +
+
+ + Refresh automatically +
+ +
+
+ +
-
+ +
+
+ Send payload event to channel +
+ +
+
+ + +
+
+ + +
+
+ +
+ + + +
+ +
+ +
+
+ +
+
+ Server activity +
+ +
+
+ + + + + + + + + + + + + + + +
+ Type + + Details + + Time +
+
+ @{{ log.type }} +
+
+
@{{ log.details }}
+
+ @{{ log.time }} +
+
+
+
+
diff --git a/src/API/Controller.php b/src/API/Controller.php new file mode 100644 index 0000000000..079637afd9 --- /dev/null +++ b/src/API/Controller.php @@ -0,0 +1,277 @@ +channelManager = $channelManager; + } + + /** + * Handle the opened socket connection. + * + * @param \Ratchet\ConnectionInterface $connection + * @param \Psr\Http\Message\RequestInterface $request + * @return void + */ + public function onOpen(ConnectionInterface $connection, RequestInterface $request = null) + { + $this->request = $request; + + $this->contentLength = $this->findContentLength($request->getHeaders()); + + $this->requestBuffer = (string) $request->getBody(); + + if (! $this->verifyContentLength()) { + return; + } + + $this->handleRequest($connection); + } + + /** + * Handle the oncoming message and add it to buffer. + * + * @param \Ratchet\ConnectionInterface $from + * @param mixed $msg + * @return void + */ + public function onMessage(ConnectionInterface $from, $msg) + { + $this->requestBuffer .= $msg; + + if (! $this->verifyContentLength()) { + return; + } + + $this->handleRequest($from); + } + + /** + * Handle the socket closing. + * + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ + public function onClose(ConnectionInterface $connection) + { + // + } + + /** + * Handle the errors. + * + * @param \Ratchet\ConnectionInterface $connection + * @param Exception $exception + * @return void + */ + public function onError(ConnectionInterface $connection, Exception $exception) + { + if (! $exception instanceof HttpException) { + return; + } + + $response = new Response($exception->getStatusCode(), [ + 'Content-Type' => 'application/json', + ], json_encode([ + 'error' => $exception->getMessage(), + ])); + + tap($connection)->send(\GuzzleHttp\Psr7\str($response))->close(); + } + + /** + * Get the content length from the headers. + * + * @param array $headers + * @return int + */ + protected function findContentLength(array $headers): int + { + return Collection::make($headers)->first(function ($values, $header) { + return strtolower($header) === 'content-length'; + })[0] ?? 0; + } + + /** + * Check the content length. + * + * @return bool + */ + protected function verifyContentLength() + { + return strlen($this->requestBuffer) === $this->contentLength; + } + + /** + * Handle the oncoming connection. + * + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ + protected function handleRequest(ConnectionInterface $connection) + { + $serverRequest = (new ServerRequest( + $this->request->getMethod(), + $this->request->getUri(), + $this->request->getHeaders(), + $this->requestBuffer, + $this->request->getProtocolVersion() + ))->withQueryParams(QueryParameters::create($this->request)->all()); + + $laravelRequest = Request::createFromBase((new HttpFoundationFactory)->createRequest($serverRequest)); + + $this->ensureValidAppId($laravelRequest->get('appId')) + ->ensureValidSignature($laravelRequest); + + // Invoke the controller action + $response = $this($laravelRequest); + + // Allow for async IO in the controller action + if ($response instanceof PromiseInterface) { + $response->then(function ($response) use ($connection) { + $this->sendAndClose($connection, $response); + }); + + return; + } + + if ($response instanceof HttpException) { + throw $response; + } + + $this->sendAndClose($connection, $response); + } + + /** + * Send the response and close the connection. + * + * @param \Ratchet\ConnectionInterface $connection + * @param mixed $response + * @return void + */ + protected function sendAndClose(ConnectionInterface $connection, $response) + { + tap($connection)->send(JsonResponse::create($response))->close(); + } + + /** + * Ensure app existence. + * + * @param mixed $appId + * @return $this + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + */ + public function ensureValidAppId($appId) + { + if (! $appId || ! $this->app = App::findById($appId)) { + throw new HttpException(401, "Unknown app id `{$appId}` provided."); + } + + return $this; + } + + /** + * Ensure signature integrity coming from an + * authorized application. + * + * @param \GuzzleHttp\Psr7\ServerRequest $request + * @return $this + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + */ + 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', + ]); + + if ($request->getContent() !== '') { + $params['body_md5'] = md5($request->getContent()); + } + + ksort($params); + + $signature = "{$request->getMethod()}\n/{$request->path()}\n".Pusher::array_implode('=', '&', $params); + + $authSignature = hash_hmac('sha256', $signature, $this->app->secret); + + if ($authSignature !== $request->get('auth_signature')) { + throw new HttpException(401, 'Invalid auth signature provided.'); + } + + return $this; + } + + /** + * Handle the incoming request. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + abstract public function __invoke(Request $request); +} diff --git a/src/API/FetchChannel.php b/src/API/FetchChannel.php new file mode 100644 index 0000000000..a0c20faac2 --- /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 0000000000..ddd39cce45 --- /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 0000000000..532784743e --- /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 0000000000..7a3d986b87 --- /dev/null +++ b/src/API/TriggerEvent.php @@ -0,0 +1,65 @@ +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 + ); + + if ($this->app->statisticsEnabled) { + 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 05c2c23ce2..19d10f6bba 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 */ @@ -33,38 +33,63 @@ class App /** @var bool */ public $statisticsEnabled = true; + /** @var array */ + public $allowedOrigins = []; + + /** + * Find the app by id. + * + * @param string|int $appId + * @return \BeyondCode\LaravelWebSockets\Apps\App|null + */ public static function findById($appId) { - return app(AppProvider::class)->findById($appId); + return app(AppManager::class)->findById($appId); } - public static function findByKey(string $appKey): ?self + /** + * Find the app by app key. + * + * @param string $appKey + * @return \BeyondCode\LaravelWebSockets\Apps\App|null + */ + public static function findByKey($appKey): ?self { - return app(AppProvider::class)->findByKey($appKey); + return app(AppManager::class)->findByKey($appKey); } - public static function findBySecret(string $appSecret): ?self + /** + * Find the app by app secret. + * + * @param string $appSecret + * @return \BeyondCode\LaravelWebSockets\Apps\App|null + */ + public static function findBySecret($appSecret): ?self { - return app(AppProvider::class)->findBySecret($appSecret); + return app(AppManager::class)->findBySecret($appSecret); } - public function __construct($appId, string $appKey, string $appSecret) + /** + * Initialize the Web Socket app instance. + * + * @param string|int $appId + * @param string $key + * @param string $secret + * @return void + */ + 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; } + /** + * Set the name of the app. + * + * @param string $appName + * @return $this + */ public function setName(string $appName) { $this->name = $appName; @@ -72,6 +97,12 @@ public function setName(string $appName) return $this; } + /** + * Set the app host. + * + * @param string $host + * @return $this + */ public function setHost(string $host) { $this->host = $host; @@ -79,6 +110,12 @@ public function setHost(string $host) return $this; } + /** + * Set path for the app. + * + * @param string $path + * @return $this + */ public function setPath(string $path) { $this->path = $path; @@ -86,6 +123,12 @@ public function setPath(string $path) return $this; } + /** + * Enable client messages. + * + * @param bool $enabled + * @return $this + */ public function enableClientMessages(bool $enabled = true) { $this->clientMessagesEnabled = $enabled; @@ -93,6 +136,12 @@ public function enableClientMessages(bool $enabled = true) return $this; } + /** + * Set the maximum capacity for the app. + * + * @param int|null $capacity + * @return $this + */ public function setCapacity(?int $capacity) { $this->capacity = $capacity; @@ -100,10 +149,29 @@ public function setCapacity(?int $capacity) return $this; } + /** + * Enable statistics for the app. + * + * @param bool $enabled + * @return $this + */ public function enableStatistics(bool $enabled = true) { $this->statisticsEnabled = $enabled; return $this; } + + /** + * Add whitelisted origins. + * + * @param array $allowedOrigins + * @return $this + */ + public function setAllowedOrigins(array $allowedOrigins) + { + $this->allowedOrigins = $allowedOrigins; + + return $this; + } } diff --git a/src/Apps/AppProvider.php b/src/Apps/AppProvider.php deleted file mode 100644 index 02de343563..0000000000 --- a/src/Apps/AppProvider.php +++ /dev/null @@ -1,15 +0,0 @@ -apps = collect(config('websockets.apps')); + } + + /** + * Get all apps. + * + * @return array[\BeyondCode\LaravelWebSockets\Apps\App] + */ + public function all(): array + { + return $this->apps + ->map(function (array $appAttributes) { + return $this->convertIntoApp($appAttributes); + }) + ->toArray(); + } + + /** + * Get app by id. + * + * @param string|int $appId + * @return \BeyondCode\LaravelWebSockets\Apps\App|null + */ + public function findById($appId): ?App + { + return $this->convertIntoApp( + $this->apps->firstWhere('id', $appId) + ); + } + + /** + * Get app by app key. + * + * @param string $appKey + * @return \BeyondCode\LaravelWebSockets\Apps\App|null + */ + public function findByKey($appKey): ?App + { + return $this->convertIntoApp( + $this->apps->firstWhere('key', $appKey) + ); + } + + /** + * Get app by secret. + * + * @param string $appSecret + * @return \BeyondCode\LaravelWebSockets\Apps\App|null + */ + public function findBySecret($appSecret): ?App + { + return $this->convertIntoApp( + $this->apps->firstWhere('secret', $appSecret) + ); + } + + /** + * Map the app into an App instance. + * + * @param array|null $app + * @return \BeyondCode\LaravelWebSockets\Apps\App|null + */ + protected function convertIntoApp(?array $appAttributes): ?App + { + if (! $appAttributes) { + return null; + } + + $app = new App( + $appAttributes['id'], + $appAttributes['key'], + $appAttributes['secret'] + ); + + if (isset($appAttributes['name'])) { + $app->setName($appAttributes['name']); + } + + if (isset($appAttributes['host'])) { + $app->setHost($appAttributes['host']); + } + + if (isset($appAttributes['path'])) { + $app->setPath($appAttributes['path']); + } + + $app + ->enableClientMessages($appAttributes['enable_client_messages']) + ->enableStatistics($appAttributes['enable_statistics']) + ->setCapacity($appAttributes['capacity'] ?? null) + ->setAllowedOrigins($appAttributes['allowed_origins'] ?? []); + + return $app; + } +} diff --git a/src/Apps/ConfigAppProvider.php b/src/Apps/ConfigAppProvider.php deleted file mode 100644 index 69d8bfeb2c..0000000000 --- a/src/Apps/ConfigAppProvider.php +++ /dev/null @@ -1,85 +0,0 @@ -apps = collect(config('websockets.apps')); - } - - /** @return array[\BeyondCode\LaravelWebSockets\AppProviders\App] */ - public function all(): array - { - return $this->apps - ->map(function (array $appAttributes) { - return $this->instanciate($appAttributes); - }) - ->toArray(); - } - - public function findById($appId): ?App - { - $appAttributes = $this - ->apps - ->firstWhere('id', $appId); - - return $this->instanciate($appAttributes); - } - - public function findByKey(string $appKey): ?App - { - $appAttributes = $this - ->apps - ->firstWhere('key', $appKey); - - return $this->instanciate($appAttributes); - } - - public function findBySecret(string $appSecret): ?App - { - $appAttributes = $this - ->apps - ->firstWhere('secret', $appSecret); - - return $this->instanciate($appAttributes); - } - - protected function instanciate(?array $appAttributes): ?App - { - if (! $appAttributes) { - return null; - } - - $app = new App( - $appAttributes['id'], - $appAttributes['key'], - $appAttributes['secret'] - ); - - if (isset($appAttributes['name'])) { - $app->setName($appAttributes['name']); - } - - if (isset($appAttributes['host'])) { - $app->setHost($appAttributes['host']); - } - - if (isset($appAttributes['path'])) { - $app->setPath($appAttributes['path']); - } - - $app - ->enableClientMessages($appAttributes['enable_client_messages']) - ->enableStatistics($appAttributes['enable_statistics']) - ->setCapacity($appAttributes['capacity'] ?? null); - - return $app; - } -} diff --git a/src/ChannelManagers/LocalChannelManager.php b/src/ChannelManagers/LocalChannelManager.php new file mode 100644 index 0000000000..864857b147 --- /dev/null +++ b/src/ChannelManagers/LocalChannelManager.php @@ -0,0 +1,539 @@ +store = new ArrayStore; + $this->serverId = Str::uuid()->toString(); + } + + /** + * 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 Helpers::createFulfilledPromise(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 the unique identifier for the server. + * + * @return string + */ + public function getServerId(): string + { + return $this->serverId; + } + + /** + * 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 0000000000..f96aff2567 --- /dev/null +++ b/src/ChannelManagers/RedisChannelManager.php @@ -0,0 +1,828 @@ +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); + }); + } + + /** + * 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->getChannelsRedisHash($appId) + ); + } + + /** + * 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 + * 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->getStatsRedisHash($appId, $channelName), '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->getRedisTopicName($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->getUsersRedisHash($appId, $channel)) + ->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->getUsersRedisHash($connection->app->id, $channel), $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->getUsersRedisHash($appId, $channel) + ); + } + + 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->getUserSocketsRedisHash($appId, $channelName, $userId) + ); + } + + /** + * 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; + + DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_MESSAGE_RECEIVED, [ + 'fromServerId' => $serverId, + 'fromSocketId' => $socketId, + 'receiverServerId' => $this->getServerId(), + 'channel' => $channel, + 'payload' => $payload, + ]); + + 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.modes.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['db'] = $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(); + } + + /** + * 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->getStatsRedisHash($appId, $channel), '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->getSocketsRedisHash(), + $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->getSocketsRedisHash(), + "{$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->getSocketsRedisHash(), $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->getChannelsRedisHash($appId), $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->getChannelsRedisHash($appId), $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->getUsersRedisHash($appId, $channel), $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->getUsersRedisHash($appId, $channel), $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 + { + $topic = $this->getRedisTopicName($appId, $channel); + + DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_SUBSCRIBED, [ + 'serverId' => $this->getServerId(), + 'pubsubTopic' => $topic, + ]); + + return $this->subscribeClient->subscribe($topic); + } + + /** + * 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 + { + $topic = $this->getRedisTopicName($appId, $channel); + + DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_UNSUBSCRIBED, [ + 'serverId' => $this->getServerId(), + 'pubsubTopic' => $topic, + ]); + + return $this->subscribeClient->unsubscribe($topic); + } + + /** + * 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->getUserSocketsRedisHash($appId, $channel, $user->user_id), $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->getUserSocketsRedisHash($appId, $channel, $user->user_id), $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 the statistics Redis hash. + * + * @param string|int $appId + * @param string|null $channel + * @return string + */ + public function getStatsRedisHash($appId, string $channel = null): string + { + return $this->getRedisKey($appId, $channel, ['stats']); + } + + /** + * Get the sockets Redis hash used to store all sockets ids. + * + * @return string + */ + public function getSocketsRedisHash(): string + { + return $this->getRedisKey(null, null, ['sockets']); + } + + /** + * Get the channels Redis hash for a specific app id, used + * to store existing channels. + * + * @param string|int $appId + * @return string + */ + public function getChannelsRedisHash($appId): string + { + return $this->getRedisKey($appId, null, ['channels']); + } + + /** + * Get the Redis hash for storing presence channels users. + * + * @param string|int $appId + * @param string|null $channel + * @return string + */ + public function getUsersRedisHash($appId, string $channel = null): string + { + return $this->getRedisKey($appId, $channel, ['users']); + } + + /** + * Get the Redis hash for storing socket ids + * for a specific presence channels user. + * + * @param string|int $appId + * @param string|null $channel + * @param string|int|null $userId + * @return string + */ + public function getUserSocketsRedisHash($appId, string $channel = null, $userId = null): string + { + return $this->getRedisKey($appId, $channel, [$userId, 'userSockets']); + } + + /** + * Get the Redis topic name for PubSub + * used to transfer info between servers. + * + * @param string|int $appId + * @param string|null $channel + * @return string + */ + public function getRedisTopicName($appId, string $channel = null): string + { + return $this->getRedisKey($appId, $channel); + } + + /** + * 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 0000000000..fd857e233f --- /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 0000000000..614fe8da50 --- /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/Channels/PrivateChannel.php b/src/Channels/PrivateChannel.php new file mode 100644 index 0000000000..93914e5e28 --- /dev/null +++ b/src/Channels/PrivateChannel.php @@ -0,0 +1,26 @@ +verifySignature($connection, $payload); + + return parent::subscribe($connection, $payload); + } +} diff --git a/src/Concerns/PushesToPusher.php b/src/Concerns/PushesToPusher.php new file mode 100644 index 0000000000..e50dafdb4c --- /dev/null +++ b/src/Concerns/PushesToPusher.php @@ -0,0 +1,27 @@ +comment('Cleaning WebSocket Statistics...'); - - $appId = $this->argument('appId'); - - $maxAgeInDays = config('websockets.statistics.delete_statistics_older_than_days'); - - $cutOffDate = Carbon::now()->subDay($maxAgeInDays)->format('Y-m-d H:i:s'); - - $webSocketsStatisticsEntryModelClass = config('websockets.statistics.model'); - - $amountDeleted = $webSocketsStatisticsEntryModelClass::where('created_at', '<', $cutOffDate) - ->when(! is_null($appId), function (Builder $query) use ($appId) { - $query->where('app_id', $appId); - }) - ->delete(); - - $this->info("Deleted {$amountDeleted} record(s) from the WebSocket statistics."); - - $this->comment('All done!'); - } -} diff --git a/src/Console/Commands/CleanStatistics.php b/src/Console/Commands/CleanStatistics.php new file mode 100644 index 0000000000..763b9f50cc --- /dev/null +++ b/src/Console/Commands/CleanStatistics.php @@ -0,0 +1,44 @@ +comment('Cleaning WebSocket Statistics...'); + + $days = $this->option('days') ?: config('statistics.delete_statistics_older_than_days'); + + $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 0000000000..274129f498 --- /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/Commands/RestartServer.php b/src/Console/Commands/RestartServer.php new file mode 100644 index 0000000000..69fe58f785 --- /dev/null +++ b/src/Console/Commands/RestartServer.php @@ -0,0 +1,43 @@ +currentTime() + ); + + $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 0000000000..f9bb71c1ab --- /dev/null +++ b/src/Console/Commands/StartServer.php @@ -0,0 +1,321 @@ +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. + + if (! extension_loaded('pcntl')) { + return; + } + + $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/RestartWebSocketServer.php b/src/Console/RestartWebSocketServer.php deleted file mode 100644 index 26d240cb76..0000000000 --- a/src/Console/RestartWebSocketServer.php +++ /dev/null @@ -1,23 +0,0 @@ -currentTime()); - - $this->info('Broadcasting WebSocket server restart signal.'); - } -} diff --git a/src/Console/StartWebSocketServer.php b/src/Console/StartWebSocketServer.php deleted file mode 100644 index 89dd205b35..0000000000 --- a/src/Console/StartWebSocketServer.php +++ /dev/null @@ -1,177 +0,0 @@ -loop = LoopFactory::create(); - } - - public function handle() - { - $this - ->configureStatisticsLogger() - ->configureHttpLogger() - ->configureMessageLogger() - ->configureConnectionLogger() - ->configureRestartTimer() - ->registerEchoRoutes() - ->registerCustomRoutes() - ->startWebSocketServer(); - } - - protected function configureStatisticsLogger() - { - $connector = new Connector($this->loop, [ - 'dns' => $this->getDnsResolver(), - 'tls' => [ - 'verify_peer' => config('app.env') === 'production', - 'verify_peer_name' => config('app.env') === 'production', - ], - ]); - - $browser = new Browser($this->loop, $connector); - - app()->singleton(StatisticsLoggerInterface::class, function () use ($browser) { - $class = config('websockets.statistics.logger', \BeyondCode\LaravelWebSockets\Statistics\Logger\HttpStatisticsLogger::class); - - return new $class(app(ChannelManager::class), $browser); - }); - - $this->loop->addPeriodicTimer(config('websockets.statistics.interval_in_seconds'), function () { - StatisticsLogger::save(); - }); - - return $this; - } - - protected function configureHttpLogger() - { - app()->singleton(HttpLogger::class, function () { - return (new HttpLogger($this->output)) - ->enable($this->option('debug') ?: config('app.debug')) - ->verbose($this->output->isVerbose()); - }); - - return $this; - } - - protected function configureMessageLogger() - { - app()->singleton(WebsocketsLogger::class, function () { - return (new WebsocketsLogger($this->output)) - ->enable($this->option('debug') ?: config('app.debug')) - ->verbose($this->output->isVerbose()); - }); - - return $this; - } - - protected function configureConnectionLogger() - { - app()->bind(ConnectionLogger::class, function () { - return (new ConnectionLogger($this->output)) - ->enable(config('app.debug')) - ->verbose($this->output->isVerbose()); - }); - - return $this; - } - - public function configureRestartTimer() - { - $this->lastRestart = $this->getLastRestart(); - - $this->loop->addPeriodicTimer(10, function () { - if ($this->getLastRestart() !== $this->lastRestart) { - $this->loop->stop(); - } - }); - - return $this; - } - - protected function registerEchoRoutes() - { - WebSocketsRouter::echo(); - - return $this; - } - - protected function registerCustomRoutes() - { - WebSocketsRouter::customRoutes(); - - return $this; - } - - protected function startWebSocketServer() - { - $this->info("Starting the WebSocket server on port {$this->option('port')}..."); - - $routes = WebSocketsRouter::getRoutes(); - - /* 🛰 Start the server 🛰 */ - (new WebSocketServerFactory()) - ->setLoop($this->loop) - ->useRoutes($routes) - ->setHost($this->option('host')) - ->setPort($this->option('port')) - ->setConsoleOutput($this->output) - ->createServer() - ->run(); - } - - protected function getDnsResolver(): ResolverInterface - { - if (! config('websockets.statistics.perform_dns_lookup')) { - return new DnsResolver; - } - - $dnsConfig = DnsConfig::loadSystemConfigBlocking(); - - return (new DnsFactory)->createCached( - $dnsConfig->nameservers - ? reset($dnsConfig->nameservers) - : '1.1.1.1', - $this->loop - ); - } - - protected function getLastRestart() - { - return Cache::get('beyondcode:websockets:restart', 0); - } -} diff --git a/src/Contracts/AppManager.php b/src/Contracts/AppManager.php new file mode 100644 index 0000000000..153eda8a8d --- /dev/null +++ b/src/Contracts/AppManager.php @@ -0,0 +1,39 @@ +httpRequest; - - static::log($connection->app->id, static::TYPE_CONNECTION, [ - 'details' => "Origin: {$request->getUri()->getScheme()}://{$request->getUri()->getHost()}", - 'socketId' => $connection->socketId, - ]); - } - - public static function occupied(ConnectionInterface $connection, string $channelName) - { - static::log($connection->app->id, static::TYPE_OCCUPIED, [ - 'details' => "Channel: {$channelName}", - ]); - } - - public static function subscribed(ConnectionInterface $connection, string $channelName) - { - static::log($connection->app->id, static::TYPE_SUBSCRIBED, [ - 'socketId' => $connection->socketId, - 'details' => "Channel: {$channelName}", - ]); - } - - public static function clientMessage(ConnectionInterface $connection, stdClass $payload) - { - static::log($connection->app->id, static::TYPE_CLIENT_MESSAGE, [ - 'details' => "Channel: {$payload->channel}, Event: {$payload->event}", - 'socketId' => $connection->socketId, - 'data' => json_encode($payload), - ]); - } - - public static function disconnection(ConnectionInterface $connection) - { - static::log($connection->app->id, static::TYPE_DISCONNECTION, [ - 'socketId' => $connection->socketId, - ]); - } - - public static function vacated(ConnectionInterface $connection, string $channelName) - { - static::log($connection->app->id, static::TYPE_VACATED, [ - 'details' => "Channel: {$channelName}", - ]); - } - - public static function apiMessage($appId, string $channel, string $event, string $payload) - { - static::log($appId, static::TYPE_API_MESSAGE, [ - 'details' => "Channel: {$channel}, Event: {$event}", - 'data' => $payload, - ]); - } - - public static function log($appId, string $type, array $attributes = []) - { - $channelName = static::LOG_CHANNEL_PREFIX.$type; - - $channel = app(ChannelManager::class)->find($appId, $channelName); - - optional($channel)->broadcast([ - 'event' => 'log-message', - 'channel' => $channelName, - 'data' => [ - 'type' => $type, - 'time' => strftime('%H:%M:%S'), - ] + $attributes, - ]); - } -} diff --git a/src/Dashboard/Http/Controllers/AuthenticateDashboard.php b/src/Dashboard/Http/Controllers/AuthenticateDashboard.php index 0f9f56c388..a25922f734 100644 --- a/src/Dashboard/Http/Controllers/AuthenticateDashboard.php +++ b/src/Dashboard/Http/Controllers/AuthenticateDashboard.php @@ -3,27 +3,31 @@ namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers; use BeyondCode\LaravelWebSockets\Apps\App; +use BeyondCode\LaravelWebSockets\Concerns\PushesToPusher; use Illuminate\Broadcasting\Broadcasters\PusherBroadcaster; use Illuminate\Http\Request; -use Pusher\Pusher; class AuthenticateDashboard { + use PushesToPusher; + + /** + * Find the app by using the header + * and then reconstruct the PusherBroadcaster + * using our own app selection. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\Response + */ public function __invoke(Request $request) { - /** - * Find the app by using the header - * and then reconstruct the PusherBroadcaster - * using our own app selection. - */ - $app = App::findById($request->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 1d77b9d874..0000000000 --- a/src/Dashboard/Http/Controllers/DashboardApiController.php +++ /dev/null @@ -1,36 +0,0 @@ -latest()->limit(120)->get(); - - $statisticData = $statistics->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' => $statisticData->pluck('timestamp'), - 'y' => $statisticData->pluck('peak_connection_count'), - ], - 'websocket_message_count' => [ - 'x' => $statisticData->pluck('timestamp'), - 'y' => $statisticData->pluck('websocket_message_count'), - ], - 'api_message_count' => [ - 'x' => $statisticData->pluck('timestamp'), - 'y' => $statisticData->pluck('api_message_count'), - ], - ]; - } -} diff --git a/src/Dashboard/Http/Controllers/SendMessage.php b/src/Dashboard/Http/Controllers/SendMessage.php index fe0c75557d..781cbaf01b 100644 --- a/src/Dashboard/Http/Controllers/SendMessage.php +++ b/src/Dashboard/Http/Controllers/SendMessage.php @@ -2,42 +2,55 @@ namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers; -use BeyondCode\LaravelWebSockets\Statistics\Rules\AppId; -use Illuminate\Broadcasting\Broadcasters\PusherBroadcaster; +use BeyondCode\LaravelWebSockets\Concerns\PushesToPusher; +use BeyondCode\LaravelWebSockets\Rules\AppId; +use Exception; use Illuminate\Http\Request; -use Pusher\Pusher; class SendMessage { + use PushesToPusher; + + /** + * Send the message to the requested channel. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\Response + */ public function __invoke(Request $request) { - $validated = $request->validate([ - 'appId' => ['required', new AppId()], - 'key' => 'required', - 'secret' => 'required', - 'channel' => 'required', - 'event' => 'required', - 'data' => 'json', + $request->validate([ + 'appId' => ['required', new AppId], + 'key' => 'required|string', + 'secret' => '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); - protected function getPusherBroadcaster(array $validated): PusherBroadcaster - { - $pusher = new Pusher( - $validated['key'], - $validated['secret'], - $validated['appId'], - config('broadcasting.connections.pusher.options', []) - ); - - return new PusherBroadcaster($pusher); + $broadcaster->broadcast( + [$request->channel], + $request->event, + $decodedData ?: [] + ); + } catch (Exception $e) { + return response()->json([ + 'ok' => false, + 'exception' => $e->getMessage(), + ]); + } + + return response()->json([ + 'ok' => true, + ]); } } diff --git a/src/Dashboard/Http/Controllers/ShowDashboard.php b/src/Dashboard/Http/Controllers/ShowDashboard.php index 2ed2bb1e4a..eabd22d7c9 100644 --- a/src/Dashboard/Http/Controllers/ShowDashboard.php +++ b/src/Dashboard/Http/Controllers/ShowDashboard.php @@ -2,16 +2,27 @@ namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers; -use BeyondCode\LaravelWebSockets\Apps\AppProvider; +use BeyondCode\LaravelWebSockets\Contracts\AppManager; +use BeyondCode\LaravelWebSockets\DashboardLogger; use Illuminate\Http\Request; class ShowDashboard { - public function __invoke(Request $request, AppProvider $apps) + /** + * Show the dashboard. + * + * @param \Illuminate\Http\Request $request + * @param \BeyondCode\LaravelWebSockets\Contracts\AppManager $apps + * @return void + */ + public function __invoke(Request $request, AppManager $apps) { return view('websockets::dashboard', [ 'apps' => $apps->all(), 'port' => config('websockets.dashboard.port', 6001), + 'channels' => DashboardLogger::$channels, + 'logPrefix' => DashboardLogger::LOG_CHANNEL_PREFIX, + 'refreshInterval' => config('websockets.statistics.interval_in_seconds'), ]); } } diff --git a/src/Dashboard/Http/Controllers/ShowStatistics.php b/src/Dashboard/Http/Controllers/ShowStatistics.php new file mode 100644 index 0000000000..cec51c672e --- /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/Http/Middleware/Authorize.php b/src/Dashboard/Http/Middleware/Authorize.php index 1883c35eef..5a16343be8 100644 --- a/src/Dashboard/Http/Middleware/Authorize.php +++ b/src/Dashboard/Http/Middleware/Authorize.php @@ -6,8 +6,17 @@ class Authorize { + /** + * Authorize the current user. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return \Illuminate\Http\Response + */ public function handle($request, $next) { - return Gate::check('viewWebSocketsDashboard', [$request->user()]) ? $next($request) : abort(403); + return Gate::check('viewWebSocketsDashboard', [$request->user()]) + ? $next($request) + : abort(403); } } diff --git a/src/DashboardLogger.php b/src/DashboardLogger.php new file mode 100644 index 0000000000..495b5cecd3 --- /dev/null +++ b/src/DashboardLogger.php @@ -0,0 +1,83 @@ + 'log-message', + 'channel' => $channelName, + 'data' => [ + 'type' => $type, + 'time' => strftime('%H:%M:%S'), + 'details' => $details, + ], + ]; + + // Here you can use the ->find(), even if the channel + // does not exist on the server. If it does not exist, + // then the message simply will get broadcasted + // across the other servers. + $channel = $channelManager->find($appId, $channelName); + + if ($channel) { + $channel->broadcastLocally( + $appId, (object) $payload + ); + } + + $channelManager->broadcastAcrossServers( + $appId, null, $channelName, (object) $payload + ); + } +} diff --git a/src/Events/ConnectionClosed.php b/src/Events/ConnectionClosed.php new file mode 100644 index 0000000000..60b810be4c --- /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 0000000000..43440ebf64 --- /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 0000000000..5c8a30fe46 --- /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 0000000000..b3109f7f89 --- /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 0000000000..6e132e74b1 --- /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 0000000000..442ecb7bba --- /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 28e50d94ad..0000000000 --- a/src/Exceptions/InvalidApp.php +++ /dev/null @@ -1,30 +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 96c1a4ae8b..0000000000 --- a/src/Exceptions/InvalidWebSocketController.php +++ /dev/null @@ -1,15 +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/Controller.php b/src/HttpApi/Controllers/Controller.php deleted file mode 100644 index 492b3eb800..0000000000 --- a/src/HttpApi/Controllers/Controller.php +++ /dev/null @@ -1,147 +0,0 @@ -channelManager = $channelManager; - } - - public function onOpen(ConnectionInterface $connection, RequestInterface $request = null) - { - $this->request = $request; - - $this->contentLength = $this->findContentLength($request->getHeaders()); - - $this->requestBuffer = (string) $request->getBody(); - - $this->checkContentLength($connection); - } - - protected function findContentLength(array $headers): int - { - return Collection::make($headers)->first(function ($values, $header) { - return strtolower($header) === 'content-length'; - })[0] ?? 0; - } - - public function onMessage(ConnectionInterface $from, $msg) - { - $this->requestBuffer .= $msg; - - $this->checkContentLength($from); - } - - protected function checkContentLength(ConnectionInterface $connection) - { - if (strlen($this->requestBuffer) === $this->contentLength) { - $serverRequest = (new ServerRequest( - $this->request->getMethod(), - $this->request->getUri(), - $this->request->getHeaders(), - $this->requestBuffer, - $this->request->getProtocolVersion() - ))->withQueryParams(QueryParameters::create($this->request)->all()); - - $laravelRequest = Request::createFromBase((new HttpFoundationFactory)->createRequest($serverRequest)); - - $this - ->ensureValidAppId($laravelRequest->appId) - ->ensureValidSignature($laravelRequest); - - $response = $this($laravelRequest); - - $connection->send(JsonResponse::create($response)); - $connection->close(); - } - } - - public function onClose(ConnectionInterface $connection) - { - } - - public function onError(ConnectionInterface $connection, Exception $exception) - { - if (! $exception instanceof HttpException) { - return; - } - - $response = new Response($exception->getStatusCode(), [ - 'Content-Type' => 'application/json', - ], json_encode([ - 'error' => $exception->getMessage(), - ])); - - $connection->send(\GuzzleHttp\Psr7\str($response)); - - $connection->close(); - } - - public function ensureValidAppId(string $appId) - { - if (! App::findById($appId)) { - throw new HttpException(401, "Unknown app id `{$appId}` provided."); - } - - return $this; - } - - 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 paramaters 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()); - } - - ksort($params); - - $signature = "{$request->getMethod()}\n/{$request->path()}\n".Pusher::array_implode('=', '&', $params); - - $authSignature = hash_hmac('sha256', $signature, App::findById($request->get('appId'))->secret); - - if ($authSignature !== $request->get('auth_signature')) { - throw new HttpException(401, 'Invalid auth signature provided.'); - } - - return $this; - } - - abstract public function __invoke(Request $request); -} diff --git a/src/HttpApi/Controllers/FetchChannelController.php b/src/HttpApi/Controllers/FetchChannelController.php deleted file mode 100644 index 6a24fd5e2c..0000000000 --- a/src/HttpApi/Controllers/FetchChannelController.php +++ /dev/null @@ -1,20 +0,0 @@ -channelManager->find($request->appId, $request->channelName); - - if (is_null($channel)) { - throw new HttpException(404, "Unknown channel `{$request->channelName}`."); - } - - return $channel->toArray(); - } -} diff --git a/src/HttpApi/Controllers/FetchChannelsController.php b/src/HttpApi/Controllers/FetchChannelsController.php deleted file mode 100644 index 89dab3373b..0000000000 --- a/src/HttpApi/Controllers/FetchChannelsController.php +++ /dev/null @@ -1,43 +0,0 @@ -has('info')) { - $attributes = explode(',', trim($request->info)); - - if (in_array('user_count', $attributes) && ! Str::startsWith($request->filter_by_prefix, 'presence-')) { - throw new HttpException(400, 'Request must be limited to presence channels in order to fetch user_count'); - } - } - - $channels = Collection::make($this->channelManager->getChannels($request->appId)); - - if ($request->has('filter_by_prefix')) { - $channels = $channels->filter(function ($channel, $channelName) use ($request) { - return Str::startsWith($channelName, $request->filter_by_prefix); - }); - } - - return [ - 'channels' => $channels->map(function ($channel) use ($attributes) { - $info = new \stdClass; - if (in_array('user_count', $attributes)) { - $info->user_count = count($channel->getUsers()); - } - - return $info; - })->toArray() ?: new \stdClass, - ]; - } -} diff --git a/src/HttpApi/Controllers/FetchUsersController.php b/src/HttpApi/Controllers/FetchUsersController.php deleted file mode 100644 index 81f3dd0a2b..0000000000 --- a/src/HttpApi/Controllers/FetchUsersController.php +++ /dev/null @@ -1,30 +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 [ - 'users' => Collection::make($channel->getUsers())->keys()->map(function ($userId) { - return ['id' => $userId]; - })->values(), - ]; - } -} diff --git a/src/HttpApi/Controllers/TriggerEventController.php b/src/HttpApi/Controllers/TriggerEventController.php deleted file mode 100644 index 7f0000569b..0000000000 --- a/src/HttpApi/Controllers/TriggerEventController.php +++ /dev/null @@ -1,36 +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')); - - DashboardLogger::apiMessage( - $request->appId, - $channelName, - $request->json()->get('name'), - $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 60% rename from src/Statistics/Models/WebSocketsStatisticsEntry.php rename to src/Models/WebSocketsStatisticsEntry.php index 24f0a7f87c..e1d0d6be6e 100644 --- a/src/Statistics/Models/WebSocketsStatisticsEntry.php +++ b/src/Models/WebSocketsStatisticsEntry.php @@ -1,12 +1,18 @@ 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 0000000000..6f9874da29 --- /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/Rules/AppId.php b/src/Rules/AppId.php new file mode 100644 index 0000000000..ce5ea2eae5 --- /dev/null +++ b/src/Rules/AppId.php @@ -0,0 +1,33 @@ +findById($value) ? true : false; + } + + /** + * The validation message. + * + * @return string + */ + public function message() + { + return 'There is no app registered with the given id. Make sure the websockets config file contains an app for this id or that your custom AppManager returns an app for this id.'; + } +} diff --git a/src/Server/Exceptions/ConnectionsOverCapacity.php b/src/Server/Exceptions/ConnectionsOverCapacity.php new file mode 100644 index 0000000000..37f04952ee --- /dev/null +++ b/src/Server/Exceptions/ConnectionsOverCapacity.php @@ -0,0 +1,17 @@ +trigger('Over capacity', 4100); + } +} diff --git a/src/Server/Exceptions/InvalidSignature.php b/src/Server/Exceptions/InvalidSignature.php new file mode 100644 index 0000000000..b2aaf796ca --- /dev/null +++ b/src/Server/Exceptions/InvalidSignature.php @@ -0,0 +1,17 @@ +trigger('Invalid Signature', 4009); + } +} diff --git a/src/Server/Exceptions/OriginNotAllowed.php b/src/Server/Exceptions/OriginNotAllowed.php new file mode 100644 index 0000000000..cd24fff0ce --- /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 0000000000..013d9bee3a --- /dev/null +++ b/src/Server/Exceptions/UnknownAppKey.php @@ -0,0 +1,17 @@ +trigger("Could not find app key `{$appKey}`.", 4001); + } +} diff --git a/src/Server/Exceptions/WebSocketException.php b/src/Server/Exceptions/WebSocketException.php new file mode 100644 index 0000000000..cc7cbf920d --- /dev/null +++ b/src/Server/Exceptions/WebSocketException.php @@ -0,0 +1,37 @@ + 'pusher:error', + 'data' => [ + 'message' => $this->getMessage(), + 'code' => $this->getCode(), + ], + ]; + } + + /** + * Trigger the exception message. + * + * @param string $message + * @param int $code + * @return void + */ + public function trigger(string $message, int $code = 4001) + { + $this->message = $message; + $this->code = $code; + } +} diff --git a/src/Server/HealthHandler.php b/src/Server/HealthHandler.php new file mode 100644 index 0000000000..73186c4fd2 --- /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 53cf1b79d6..a9f4d0c299 100644 --- a/src/Server/HttpServer.php +++ b/src/Server/HttpServer.php @@ -2,10 +2,18 @@ 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. + * + * @param \Ratchet\Http\HttpServerInterface $component + * @param int $maxRequestSize + * @return void + */ public function __construct(HttpServerInterface $component, int $maxRequestSize = 4096) { parent::__construct($component); diff --git a/src/Server/Logger/Logger.php b/src/Server/Logger/Logger.php deleted file mode 100644 index ee62d4c908..0000000000 --- a/src/Server/Logger/Logger.php +++ /dev/null @@ -1,70 +0,0 @@ -enabled; - } - - public function __construct(OutputInterface $consoleOutput) - { - $this->consoleOutput = $consoleOutput; - } - - public function enable($enabled = true) - { - $this->enabled = $enabled; - - return $this; - } - - public function verbose($verbose = false) - { - $this->verbose = $verbose; - - return $this; - } - - protected function info(string $message) - { - $this->line($message, 'info'); - } - - protected function warn(string $message) - { - if (! $this->consoleOutput->getFormatter()->hasStyle('warning')) { - $style = new OutputFormatterStyle('yellow'); - - $this->consoleOutput->getFormatter()->setStyle('warning', $style); - } - - $this->line($message, 'warning'); - } - - protected function error(string $message) - { - $this->line($message, 'error'); - } - - protected function line(string $message, string $style) - { - $styled = $style ? "<$style>$message" : $message; - - $this->consoleOutput->writeln($styled); - } -} diff --git a/src/Server/Logger/ConnectionLogger.php b/src/Server/Loggers/ConnectionLogger.php similarity index 60% rename from src/Server/Logger/ConnectionLogger.php rename to src/Server/Loggers/ConnectionLogger.php index 154c6c2588..60e2ffbe1f 100644 --- a/src/Server/Logger/ConnectionLogger.php +++ b/src/Server/Loggers/ConnectionLogger.php @@ -1,14 +1,24 @@ setConnection($app); } + /** + * Set a new connection to watch. + * + * @param \Ratchet\ConnectionInterface $connection + * @return $this + */ public function setConnection(ConnectionInterface $connection) { $this->connection = $connection; @@ -23,11 +39,12 @@ public function setConnection(ConnectionInterface $connection) return $this; } - protected function getConnection() - { - return $this->connection; - } - + /** + * Send data through the connection. + * + * @param string $data + * @return void + */ public function send($data) { $socketId = $this->connection->socketId ?? null; @@ -37,6 +54,11 @@ public function send($data) $this->connection->send($data); } + /** + * Close the connection. + * + * @return void + */ public function close() { $this->warn("Connection id {$this->connection->socketId} closing."); @@ -44,21 +66,33 @@ public function close() $this->connection->close(); } + /** + * {@inheritdoc} + */ public function __set($name, $value) { return $this->connection->$name = $value; } + /** + * {@inheritdoc} + */ public function __get($name) { return $this->connection->$name; } + /** + * {@inheritdoc} + */ public function __isset($name) { return isset($this->connection->$name); } + /** + * {@inheritdoc} + */ public function __unset($name) { unset($this->connection->$name); diff --git a/src/Server/Logger/HttpLogger.php b/src/Server/Loggers/HttpLogger.php similarity index 54% rename from src/Server/Logger/HttpLogger.php rename to src/Server/Loggers/HttpLogger.php index b60b09983e..f3cbfeaf17 100644 --- a/src/Server/Logger/HttpLogger.php +++ b/src/Server/Loggers/HttpLogger.php @@ -1,6 +1,6 @@ setApp($app); } + /** + * Set a new app to watch. + * + * @param \Ratchet\MessageComponentInterface $app + * @return $this + */ public function setApp(MessageComponentInterface $app) { $this->app = $app; @@ -25,21 +41,47 @@ public function setApp(MessageComponentInterface $app) return $this; } + /** + * Handle the HTTP open request. + * + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ public function onOpen(ConnectionInterface $connection) { $this->app->onOpen($connection); } + /** + * Handle the HTTP message request. + * + * @param \Ratchet\ConnectionInterface $connection + * @param mixed $message + * @return void + */ public function onMessage(ConnectionInterface $connection, $message) { $this->app->onMessage($connection, $message); } + /** + * Handle the HTTP close request. + * + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ public function onClose(ConnectionInterface $connection) { $this->app->onClose($connection); } + /** + * Handle HTTP errors. + * + * @param \Ratchet\ConnectionInterface $connection + * @param Exception $exception + * @return void + */ public function onError(ConnectionInterface $connection, Exception $exception) { $exceptionClass = get_class($exception); diff --git a/src/Server/Loggers/Logger.php b/src/Server/Loggers/Logger.php new file mode 100644 index 0000000000..d6fe0652f2 --- /dev/null +++ b/src/Server/Loggers/Logger.php @@ -0,0 +1,125 @@ +enabled; + } + + /** + * Create a new Logger instance. + * + * @param \Symfony\Component\Console\Output\OutputInterface $consoleOutput + * @return void + */ + public function __construct(OutputInterface $consoleOutput) + { + $this->consoleOutput = $consoleOutput; + } + + /** + * Enable the logger. + * + * @param bool $enabled + * @return $this + */ + public function enable($enabled = true) + { + $this->enabled = $enabled; + + return $this; + } + + /** + * Enable the verbose mode. + * + * @param bool $verbose + * @return $this + */ + public function verbose($verbose = false) + { + $this->verbose = $verbose; + + return $this; + } + + /** + * Trigger an Info message. + * + * @param string $message + * @return void + */ + protected function info(string $message) + { + $this->line($message, 'info'); + } + + /** + * Trigger a Warning message. + * + * @param string $message + * @return void + */ + protected function warn(string $message) + { + if (! $this->consoleOutput->getFormatter()->hasStyle('warning')) { + $style = new OutputFormatterStyle('yellow'); + + $this->consoleOutput->getFormatter()->setStyle('warning', $style); + } + + $this->line($message, 'warning'); + } + + /** + * Trigger an Error message. + * + * @param string $message + * @return void + */ + protected function error(string $message) + { + $this->line($message, 'error'); + } + + protected function line(string $message, string $style) + { + $this->consoleOutput->writeln( + $style ? "<{$style}>{$message}" : $message + ); + } +} diff --git a/src/Server/Logger/WebsocketsLogger.php b/src/Server/Loggers/WebSocketsLogger.php similarity index 60% rename from src/Server/Logger/WebsocketsLogger.php rename to src/Server/Loggers/WebSocketsLogger.php index 0f95f2eb37..a9555e1f13 100644 --- a/src/Server/Logger/WebsocketsLogger.php +++ b/src/Server/Loggers/WebSocketsLogger.php @@ -1,18 +1,28 @@ setApp($app); } + /** + * Set a new app to watch. + * + * @param \Ratchet\MessageComponentInterface $app + * @return $this + */ public function setApp(MessageComponentInterface $app) { $this->app = $app; @@ -27,6 +43,12 @@ public function setApp(MessageComponentInterface $app) return $this; } + /** + * Handle the HTTP open request. + * + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ public function onOpen(ConnectionInterface $connection) { $appKey = QueryParameters::create($connection->httpRequest)->get('appKey'); @@ -36,6 +58,13 @@ public function onOpen(ConnectionInterface $connection) $this->app->onOpen(ConnectionLogger::decorate($connection)); } + /** + * Handle the HTTP message request. + * + * @param \Ratchet\ConnectionInterface $connection + * @param \Ratchet\RFC6455\Messaging\MessageInterface $message + * @return void + */ public function onMessage(ConnectionInterface $connection, MessageInterface $message) { $this->info("{$connection->app->id}: connection id {$connection->socketId} received message: {$message->getPayload()}."); @@ -43,6 +72,12 @@ public function onMessage(ConnectionInterface $connection, MessageInterface $mes $this->app->onMessage(ConnectionLogger::decorate($connection), $message); } + /** + * Handle the HTTP close request. + * + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ public function onClose(ConnectionInterface $connection) { $socketId = $connection->socketId ?? null; @@ -52,6 +87,13 @@ public function onClose(ConnectionInterface $connection) $this->app->onClose(ConnectionLogger::decorate($connection)); } + /** + * Handle HTTP errors. + * + * @param \Ratchet\ConnectionInterface $connection + * @param Exception $exception + * @return void + */ public function onError(ConnectionInterface $connection, Exception $exception) { $exceptionClass = get_class($exception); diff --git a/src/Server/Messages/PusherChannelProtocolMessage.php b/src/Server/Messages/PusherChannelProtocolMessage.php new file mode 100644 index 0000000000..c6f4f13472 --- /dev/null +++ b/src/Server/Messages/PusherChannelProtocolMessage.php @@ -0,0 +1,68 @@ +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/Server/Messages/PusherClientMessage.php b/src/Server/Messages/PusherClientMessage.php new file mode 100644 index 0000000000..7b4dc64d8a --- /dev/null +++ b/src/Server/Messages/PusherClientMessage.php @@ -0,0 +1,79 @@ +payload = $payload; + $this->connection = $connection; + $this->channelManager = $channelManager; + } + + /** + * Respond to the message construction. + * + * @return void + */ + public function respond() + { + if (! Str::startsWith($this->payload->event, 'client-')) { + return; + } + + if (! $this->connection->app->clientMessagesEnabled) { + return; + } + + $channel = $this->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, + 'event' => $this->payload->event, + 'channel' => $this->payload->channel, + 'data' => $this->payload, + ]); + } +} diff --git a/src/WebSockets/Messages/PusherMessageFactory.php b/src/Server/Messages/PusherMessageFactory.php similarity index 56% rename from src/WebSockets/Messages/PusherMessageFactory.php rename to src/Server/Messages/PusherMessageFactory.php index 7fbe512da4..253252b802 100644 --- a/src/WebSockets/Messages/PusherMessageFactory.php +++ b/src/Server/Messages/PusherMessageFactory.php @@ -1,14 +1,23 @@ 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/Server/OriginCheck.php b/src/Server/OriginCheck.php deleted file mode 100644 index 5a3bd050c5..0000000000 --- a/src/Server/OriginCheck.php +++ /dev/null @@ -1,60 +0,0 @@ -_component = $component; - - $this->allowedOrigins = $allowedOrigins; - } - - public function onOpen(ConnectionInterface $connection, RequestInterface $request = null) - { - if ($request->hasHeader('Origin')) { - $this->verifyOrigin($connection, $request); - } - - return $this->_component->onOpen($connection, $request); - } - - public function onMessage(ConnectionInterface $from, $msg) - { - return $this->_component->onMessage($from, $msg); - } - - public function onClose(ConnectionInterface $connection) - { - return $this->_component->onClose($connection); - } - - public function onError(ConnectionInterface $connection, \Exception $e) - { - return $this->_component->onError($connection, $e); - } - - protected function verifyOrigin(ConnectionInterface $connection, RequestInterface $request) - { - $header = (string) $request->getHeader('Origin')[0]; - $origin = parse_url($header, PHP_URL_HOST) ?: $header; - - if (! empty($this->allowedOrigins) && ! in_array($origin, $this->allowedOrigins)) { - return $this->close($connection, 403); - } - } -} diff --git a/src/QueryParameters.php b/src/Server/QueryParameters.php similarity index 56% rename from src/QueryParameters.php rename to src/Server/QueryParameters.php index 85ee8af9a2..56d324e589 100644 --- a/src/QueryParameters.php +++ b/src/Server/QueryParameters.php @@ -1,12 +1,16 @@ request = $request; } + /** + * Get all query parameters. + * + * @return array + */ public function all(): array { $queryParameters = []; @@ -28,6 +43,12 @@ public function all(): array return $queryParameters; } + /** + * Get a specific query parameter. + * + * @param string $name + * @return string + */ public function get(string $name): string { return $this->all()[$name] ?? ''; diff --git a/src/Server/Router.php b/src/Server/Router.php index 110b2d915a..bda9878bdd 100644 --- a/src/Server/Router.php +++ b/src/Server/Router.php @@ -2,14 +2,7 @@ namespace BeyondCode\LaravelWebSockets\Server; -use BeyondCode\LaravelWebSockets\Exceptions\InvalidWebSocketController; -use BeyondCode\LaravelWebSockets\HttpApi\Controllers\FetchChannelController; -use BeyondCode\LaravelWebSockets\HttpApi\Controllers\FetchChannelsController; -use BeyondCode\LaravelWebSockets\HttpApi\Controllers\FetchUsersController; -use BeyondCode\LaravelWebSockets\HttpApi\Controllers\TriggerEventController; -use BeyondCode\LaravelWebSockets\Server\Logger\WebsocketsLogger; -use BeyondCode\LaravelWebSockets\WebSockets\WebSocketHandler; -use Illuminate\Support\Collection; +use BeyondCode\LaravelWebSockets\Server\Loggers\WebSocketsLogger; use Ratchet\WebSocket\MessageComponentInterface; use Ratchet\WebSocket\WsServer; use Symfony\Component\Routing\Route; @@ -17,85 +10,131 @@ class Router { - /** @var \Symfony\Component\Routing\RouteCollection */ + /** + * The implemented routes. + * + * @var \Symfony\Component\Routing\RouteCollection + */ protected $routes; - protected $customRoutes; + /** + * Initialize the class. + * + * @return void + */ public function __construct() { $this->routes = new RouteCollection; - $this->customRoutes = new Collection(); } + /** + * Get the routes. + * + * @return \Symfony\Component\Routing\RouteCollection + */ public function getRoutes(): RouteCollection { return $this->routes; } - public function echo() + /** + * Register the default routes. + * + * @return void + */ + public function routes() { - $this->get('/app/{appKey}', 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); - } - - public function customRoutes() - { - $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')); } + /** + * Add a GET route. + * + * @param string $uri + * @param string $action + * @return void + */ public function get(string $uri, $action) { $this->addRoute('GET', $uri, $action); } + /** + * Add a POST route. + * + * @param string $uri + * @param string $action + * @return void + */ public function post(string $uri, $action) { $this->addRoute('POST', $uri, $action); } + /** + * Add a PUT route. + * + * @param string $uri + * @param string $action + * @return void + */ public function put(string $uri, $action) { $this->addRoute('PUT', $uri, $action); } + /** + * Add a PATCH route. + * + * @param string $uri + * @param string $action + * @return void + */ public function patch(string $uri, $action) { $this->addRoute('PATCH', $uri, $action); } + /** + * Add a DELETE route. + * + * @param string $uri + * @param string $action + * @return void + */ public function delete(string $uri, $action) { $this->addRoute('DELETE', $uri, $action); } - 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. + * + * @param string $method + * @param string $uri + * @param string $action + * @return void + */ public function addRoute(string $method, string $uri, $action) { $this->routes->add($uri, $this->getRoute($method, $uri, $action)); } + /** + * Get the route of a specified method, uri and action. + * + * @param string $method + * @param string $uri + * @param string $action + * @return \Symfony\Component\Routing\Route + */ 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 instanciate it. - */ $action = is_subclass_of($action, MessageComponentInterface::class) ? $this->createWebSocketsServer($action) : app($action); @@ -103,6 +142,12 @@ protected function getRoute(string $method, string $uri, $action): Route return new Route($uri, ['_controller' => $action], [], [], null, [], [$method]); } + /** + * Create a new websockets server to handle the action. + * + * @param string $action + * @return \Ratchet\WebSocket\WsServer + */ protected function createWebSocketsServer(string $action): WsServer { $app = app($action); diff --git a/src/Server/WebSocketHandler.php b/src/Server/WebSocketHandler.php new file mode 100644 index 0000000000..855532dd3f --- /dev/null +++ b/src/Server/WebSocketHandler.php @@ -0,0 +1,261 @@ +channelManager = $channelManager; + } + + /** + * Handle the socket opening. + * + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ + public function onOpen(ConnectionInterface $connection) + { + if (! $this->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; + + if ($connection->app->statisticsEnabled) { + 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); + } + } + + /** + * Handle the incoming message. + * + * @param \Ratchet\ConnectionInterface $connection + * @param \Ratchet\RFC6455\Messaging\MessageInterface $message + * @return void + */ + public function onMessage(ConnectionInterface $connection, MessageInterface $message) + { + if (! isset($connection->app)) { + return; + } + + Messages\PusherMessageFactory::createForMessage( + $message, $connection, $this->channelManager + )->respond(); + + if ($connection->app->statisticsEnabled) { + StatisticsCollector::webSocketMessage($connection->app->id); + } + + WebSocketMessageReceived::dispatch( + $connection->app->id, + $connection->socketId, + $message + ); + } + + /** + * Handle the websocket close. + * + * @param \Ratchet\ConnectionInterface $connection + * @return void + */ + public function onClose(ConnectionInterface $connection) + { + $this->channelManager + ->unsubscribeFromAllChannels($connection) + ->then(function (bool $unsubscribed) use ($connection) { + if (isset($connection->app)) { + if ($connection->app->statisticsEnabled) { + StatisticsCollector::disconnection($connection->app->id); + } + + $this->channelManager->unsubscribeFromApp($connection->app->id); + + DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_DISCONNECTED, [ + 'socketId' => $connection->socketId, + ]); + + ConnectionClosed::dispatch($connection->app->id, $connection->socketId); + } + }); + } + + /** + * Handle the websocket errors. + * + * @param \Ratchet\ConnectionInterface $connection + * @param WebSocketException $exception + * @return void + */ + public function onError(ConnectionInterface $connection, Exception $exception) + { + 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. + * + * @param \Ratchet\ConnectionInterface $connection + * @return $this + */ + protected function verifyAppKey(ConnectionInterface $connection) + { + $query = QueryParameters::create($connection->httpRequest); + + $appKey = $query->get('appKey'); + + if (! $app = App::findByKey($appKey)) { + throw new Exceptions\UnknownAppKey($appKey); + } + + $connection->app = $app; + + return $this; + } + + /** + * Verify the origin. + * + * @param \Ratchet\ConnectionInterface $connection + * @return $this + */ + protected function verifyOrigin(ConnectionInterface $connection) + { + if (! $connection->app->allowedOrigins) { + return $this; + } + + $header = (string) ($connection->httpRequest->getHeader('Origin')[0] ?? null); + + $origin = parse_url($header, PHP_URL_HOST) ?: $header; + + if (! $header || ! in_array($origin, $connection->app->allowedOrigins)) { + throw new Exceptions\OriginNotAllowed($connection->app->key); + } + + return $this; + } + + /** + * Limit the connections count by the app. + * + * @param \Ratchet\ConnectionInterface $connection + * @return $this + */ + protected function limitConcurrentConnections(ConnectionInterface $connection) + { + if (! is_null($capacity = $connection->app->capacity)) { + $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; + } + + /** + * Create a socket id. + * + * @param \Ratchet\ConnectionInterface $connection + * @return $this + */ + protected function generateSocketId(ConnectionInterface $connection) + { + $socketId = sprintf('%d.%d', random_int(1, 1000000000), random_int(1, 1000000000)); + + $connection->socketId = $socketId; + + return $this; + } + + /** + * Establish connection with the client. + * + * @param \Ratchet\ConnectionInterface $connection + * @return $this + */ + protected function establishConnection(ConnectionInterface $connection) + { + $connection->send(json_encode([ + 'event' => 'pusher:connection_established', + 'data' => json_encode([ + 'socket_id' => $connection->socketId, + 'activity_timeout' => 30, + ]), + ])); + + return $this; + } +} diff --git a/src/Server/WebSocketServerFactory.php b/src/ServerFactory.php similarity index 51% rename from src/Server/WebSocketServerFactory.php rename to src/ServerFactory.php index 0e4ab4bc9c..f132635d83 100644 --- a/src/Server/WebSocketServerFactory.php +++ b/src/ServerFactory.php @@ -1,8 +1,9 @@ loop = LoopFactory::create(); - } - - public function useRoutes(RouteCollection $routes) - { - $this->routes = $routes; - - return $this; - } - - public function setHost(string $host) + /** + * Initialize the class. + * + * @param string $host + * @param int $port + * @return void + */ + public function __construct(string $host, int $port) { $this->host = $host; + $this->port = $port; - return $this; + $this->loop = LoopFactory::create(); } - public function setPort(string $port) + /** + * Add the routes. + * + * @param \Symfony\Component\Routing\RouteCollection $routes + * @return $this + */ + public function withRoutes(RouteCollection $routes) { - $this->port = $port; + $this->routes = $routes; return $this; } + /** + * Set the loop instance. + * + * @param \React\EventLoop\LoopInterface $loop + * @return $this + */ public function setLoop(LoopInterface $loop) { $this->loop = $loop; @@ -64,6 +93,12 @@ public function setLoop(LoopInterface $loop) return $this; } + /** + * Set the console output. + * + * @param \Symfony\Component\Console\Output\OutputInterface $consoleOutput + * @return $this + */ public function setConsoleOutput(OutputInterface $consoleOutput) { $this->consoleOutput = $consoleOutput; @@ -71,6 +106,11 @@ public function setConsoleOutput(OutputInterface $consoleOutput) return $this; } + /** + * Set up the server. + * + * @return \Ratchet\Server\IoServer + */ public function createServer(): IoServer { $socket = new Server("{$this->host}:{$this->port}", $this->loop); @@ -79,11 +119,9 @@ public function createServer(): IoServer $socket = new SecureServer($socket, $this->loop, config('websockets.ssl')); } - $urlMatcher = new UrlMatcher($this->routes, new RequestContext); - - $router = new Router($urlMatcher); - - $app = new OriginCheck($router, config('websockets.allowed_origins', [])); + $app = new Router( + new UrlMatcher($this->routes, new RequestContext) + ); $httpServer = new HttpServer($app, config('websockets.max_request_size_in_kb') * 1024); diff --git a/src/Statistics/Collectors/MemoryCollector.php b/src/Statistics/Collectors/MemoryCollector.php new file mode 100644 index 0000000000..8de17aa19e --- /dev/null +++ b/src/Statistics/Collectors/MemoryCollector.php @@ -0,0 +1,189 @@ +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; + } + + if ($statistic->shouldHaveTracesRemoved()) { + $this->resetAppTraces($appId); + + continue; + } + + $this->createRecord($statistic, $appId); + + $this->channelManager + ->getGlobalConnectionsCount($appId) + ->then(function ($connections) use ($statistic) { + $statistic->reset( + is_null($connections) ? 0 : $connections + ); + }); + } + }); + } + + /** + * 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 + ); + } + + /** + * 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) + { + unset($this->statistics[$appId]); + } + + /** + * 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 0000000000..921771a6c7 --- /dev/null +++ b/src/Statistics/Collectors/RedisCollector.php @@ -0,0 +1,374 @@ +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->getStatsRedisHash($appId, null), '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->getStatsRedisHash($appId, null), '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->getStatsRedisHash($appId, null), + 'current_connections_count', 1 + ) + ->then(function ($currentConnectionsCount) use ($appId) { + // Get the peak connections count from Redis. + $this->channelManager + ->getPublishClient() + ->hget( + $this->channelManager->getStatsRedisHash($appId, null), + '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->getStatsRedisHash($appId, null), + '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->getStatsRedisHash($appId, null), 'current_connections_count', -1) + ->then(function ($currentConnectionsCount) use ($appId) { + // Get the peak connections count from Redis. + $this->channelManager + ->getPublishClient() + ->hget($this->channelManager->getStatsRedisHash($appId, null), '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->getStatsRedisHash($appId, null), + '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->getStatsRedisHash($appId, null)) + ->then(function ($list) use ($appId) { + if (! $list) { + return; + } + + $statistic = $this->arrayToStatisticInstance( + $appId, Helpers::redisListToArray($list) + ); + + if ($statistic->shouldHaveTracesRemoved()) { + return $this->resetAppTraces($appId); + } + + $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->getStatsRedisHash($appId, null)) + ->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->getStatsRedisHash($appId, null)) + ->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->getStatsRedisHash($appId, null), + 'current_connections_count', $currentConnectionCount + ); + + $this->channelManager + ->getPublishClient() + ->hset( + $this->channelManager->getStatsRedisHash($appId, null), + 'peak_connections_count', max(0, $currentConnectionCount) + ); + + $this->channelManager + ->getPublishClient() + ->hset( + $this->channelManager->getStatsRedisHash($appId, null), + 'websocket_messages_count', 0 + ); + + $this->channelManager + ->getPublishClient() + ->hset( + $this->channelManager->getStatsRedisHash($appId, null), + '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) + { + parent::resetAppTraces($appId); + + $this->channelManager + ->getPublishClient() + ->hdel( + $this->channelManager->getStatsRedisHash($appId, null), + 'current_connections_count' + ); + + $this->channelManager + ->getPublishClient() + ->hdel( + $this->channelManager->getStatsRedisHash($appId, null), + 'peak_connections_count' + ); + + $this->channelManager + ->getPublishClient() + ->hdel( + $this->channelManager->getStatsRedisHash($appId, null), + 'websocket_messages_count' + ); + + $this->channelManager + ->getPublishClient() + ->hdel( + $this->channelManager->getStatsRedisHash($appId, null), + '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 ceca9982f3..0000000000 --- a/src/Statistics/DnsResolver.php +++ /dev/null @@ -1,39 +0,0 @@ -resolveInternal($domain); - } - - public function resolveAll($domain, $type) - { - return $this->resolveInternal($domain, $type); - } - - private function resolveInternal($domain, $type = null) - { - return new FulfilledPromise($this->internalIP); - } - - public function __toString() - { - return $this->internalIP; - } -} diff --git a/src/Statistics/Events/StatisticsUpdated.php b/src/Statistics/Events/StatisticsUpdated.php deleted file mode 100644 index e486180a85..0000000000 --- a/src/Statistics/Events/StatisticsUpdated.php +++ /dev/null @@ -1,46 +0,0 @@ -webSocketsStatisticsEntry = $webSocketsStatisticsEntry; - } - - public function broadcastWith() - { - return [ - 'time' => (string) $this->webSocketsStatisticsEntry->created_at, - 'app_id' => $this->webSocketsStatisticsEntry->app_id, - 'peak_connection_count' => $this->webSocketsStatisticsEntry->peak_connection_count, - 'websocket_message_count' => $this->webSocketsStatisticsEntry->websocket_message_count, - 'api_message_count' => $this->webSocketsStatisticsEntry->api_message_count, - ]; - } - - public function broadcastOn() - { - $channelName = Str::after(DashboardLogger::LOG_CHANNEL_PREFIX.'statistics', 'private-'); - - return new PrivateChannel($channelName); - } - - public function broadcastAs() - { - return 'statistics-updated'; - } -} diff --git a/src/Statistics/Http/Controllers/WebSocketStatisticsEntriesController.php b/src/Statistics/Http/Controllers/WebSocketStatisticsEntriesController.php deleted file mode 100644 index 8fd758c27b..0000000000 --- a/src/Statistics/Http/Controllers/WebSocketStatisticsEntriesController.php +++ /dev/null @@ -1,28 +0,0 @@ -validate([ - 'app_id' => ['required', new AppId()], - 'peak_connection_count' => 'required|integer', - 'websocket_message_count' => 'required|integer', - 'api_message_count' => 'required|integer', - ]); - - $webSocketsStatisticsEntryModelClass = config('websockets.statistics.model'); - - $statisticModel = $webSocketsStatisticsEntryModelClass::create($validatedAttributes); - - broadcast(new StatisticsUpdated($statisticModel)); - - return 'ok'; - } -} diff --git a/src/Statistics/Http/Middleware/Authorize.php b/src/Statistics/Http/Middleware/Authorize.php deleted file mode 100644 index 4611dc59f5..0000000000 --- a/src/Statistics/Http/Middleware/Authorize.php +++ /dev/null @@ -1,17 +0,0 @@ -key); - - return is_null($app) || $app->secret !== $request->secret - ? abort(403) - : $next($request); - } -} diff --git a/src/Statistics/Logger/HttpStatisticsLogger.php b/src/Statistics/Logger/HttpStatisticsLogger.php deleted file mode 100644 index 5bf35dd8da..0000000000 --- a/src/Statistics/Logger/HttpStatisticsLogger.php +++ /dev/null @@ -1,91 +0,0 @@ -channelManager = $channelManager; - - $this->browser = $browser; - } - - public function webSocketMessage(ConnectionInterface $connection) - { - $this - ->findOrMakeStatisticForAppId($connection->app->id) - ->webSocketMessage(); - } - - public function apiMessage($appId) - { - $this - ->findOrMakeStatisticForAppId($appId) - ->apiMessage(); - } - - public function connection(ConnectionInterface $connection) - { - $this - ->findOrMakeStatisticForAppId($connection->app->id) - ->connection(); - } - - public function disconnection(ConnectionInterface $connection) - { - $this - ->findOrMakeStatisticForAppId($connection->app->id) - ->disconnection(); - } - - protected function findOrMakeStatisticForAppId($appId): Statistic - { - if (! isset($this->statistics[$appId])) { - $this->statistics[$appId] = new Statistic($appId); - } - - return $this->statistics[$appId]; - } - - public function save() - { - foreach ($this->statistics as $appId => $statistic) { - if (! $statistic->isEnabled()) { - continue; - } - - $postData = array_merge($statistic->toArray(), [ - 'secret' => App::findById($appId)->secret, - ]); - - $this - ->browser - ->post( - action([WebSocketStatisticsEntriesController::class, 'store']), - ['Content-Type' => 'application/json'], - stream_for(json_encode($postData)) - ); - - $currentConnectionCount = $this->channelManager->getConnectionCount($appId); - $statistic->reset($currentConnectionCount); - } - } -} diff --git a/src/Statistics/Logger/StatisticsLogger.php b/src/Statistics/Logger/StatisticsLogger.php deleted file mode 100644 index 402a58a23c..0000000000 --- a/src/Statistics/Logger/StatisticsLogger.php +++ /dev/null @@ -1,18 +0,0 @@ -findById($value) ? true : false; - } - - public function message() - { - return 'There is no app registered with the given id. Make sure the websockets config file contains an app for this id or that your custom AppProvider returns an app for this id.'; - } -} diff --git a/src/Statistics/Statistic.php b/src/Statistics/Statistic.php index 93765fb384..b31d547ceb 100644 --- a/src/Statistics/Statistic.php +++ b/src/Statistics/Statistic.php @@ -6,70 +6,207 @@ class Statistic { - /** @var int|string */ + /** + * The app id. + * + * @var mixed + */ protected $appId; - /** @var int */ - protected $currentConnectionCount = 0; + /** + * The current connections count ticker. + * + * @var int + */ + protected $currentConnectionsCount = 0; + + /** + * The peak connections count ticker. + * + * @var int + */ + protected $peakConnectionsCount = 0; + + /** + * The websockets connections count ticker. + * + * @var int + */ + protected $webSocketMessagesCount = 0; + + /** + * The api messages connections count ticker. + * + * @var int + */ + protected $apiMessagesCount = 0; + + /** + * Create a new statistic. + * + * @param string|int $appId + * @return void + */ + public function __construct($appId) + { + $this->appId = $appId; + } - /** @var int */ - protected $peakConnectionCount = 0; + /** + * Create a new statistic instance. + * + * @param string|int $appId + * @return \BeyondCode\LaravelWebSockets\Statistics\Statistic + */ + public static function new($appId) + { + return new static($appId); + } - /** @var int */ - protected $webSocketMessageCount = 0; + /** + * Set the current connections count. + * + * @param int $currentConnectionsCount + * @return $this + */ + public function setCurrentConnectionsCount(int $currentConnectionsCount) + { + $this->currentConnectionsCount = $currentConnectionsCount; - /** @var int */ - protected $apiMessageCount = 0; + return $this; + } - public function __construct($appId) + /** + * Set the peak connections count. + * + * @param int $peakConnectionsCount + * @return $this + */ + public function setPeakConnectionsCount(int $peakConnectionsCount) { - $this->appId = $appId; + $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. + * + * @return bool + */ public function isEnabled(): bool { return App::findById($this->appId)->statisticsEnabled; } + /** + * Handle a new connection increment. + * + * @return void + */ public function connection() { - $this->currentConnectionCount++; + $this->currentConnectionsCount++; - $this->peakConnectionCount = max($this->currentConnectionCount, $this->peakConnectionCount); + $this->peakConnectionsCount = max($this->currentConnectionsCount, $this->peakConnectionsCount); } + /** + * Handle a disconnection decrement. + * + * @return void + */ public function disconnection() { - $this->currentConnectionCount--; + $this->currentConnectionsCount--; - $this->peakConnectionCount = max($this->currentConnectionCount, $this->peakConnectionCount); + $this->peakConnectionsCount = max($this->currentConnectionsCount, $this->peakConnectionsCount); } + /** + * Handle a new websocket message. + * + * @return void + */ public function webSocketMessage() { - $this->webSocketMessageCount++; + $this->webSocketMessagesCount++; } + /** + * Handle a new api message. + * + * @return void + */ public function apiMessage() { - $this->apiMessageCount++; + $this->apiMessagesCount++; + } + + /** + * Reset all the connections to a specific count. + * + * @param int $currentConnectionsCount + * @return void + */ + public function reset(int $currentConnectionsCount) + { + $this->currentConnectionsCount = $currentConnectionsCount; + $this->peakConnectionsCount = max(0, $currentConnectionsCount); + $this->webSocketMessagesCount = 0; + $this->apiMessagesCount = 0; } - public function reset(int $currentConnectionCount) + /** + * Check if the current statistic entry is empty. This means + * that the statistic entry can be easily deleted if no activity + * occured for a while. + * + * @return bool + */ + public function shouldHaveTracesRemoved(): bool { - $this->currentConnectionCount = $currentConnectionCount; - $this->peakConnectionCount = $currentConnectionCount; - $this->webSocketMessageCount = 0; - $this->apiMessageCount = 0; + return $this->currentConnectionsCount === 0 && $this->peakConnectionsCount === 0; } + /** + * Transform the statistic to array. + * + * @return array + */ public function toArray() { 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 0000000000..042e72b84f --- /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 6d41205701..0000000000 --- a/src/WebSockets/Channels/Channel.php +++ /dev/null @@ -1,119 +0,0 @@ -channelName = $channelName; - } - - public function getName(): string - { - return $this->channelName; - } - - public function hasConnections(): bool - { - return count($this->subscribedConnections) > 0; - } - - public function getSubscribedConnections(): array - { - return $this->subscribedConnections; - } - - protected function verifySignature(ConnectionInterface $connection, stdClass $payload) - { - $signature = "{$connection->socketId}:{$this->channelName}"; - - if (isset($payload->channel_data)) { - $signature .= ":{$payload->channel_data}"; - } - - if (Str::after($payload->auth, ':') !== hash_hmac('sha256', $signature, $connection->app->secret)) { - throw new InvalidSignature(); - } - } - - /* - * @link https://pusher.com/docs/pusher_protocol#presence-channel-events - */ - public function subscribe(ConnectionInterface $connection, stdClass $payload) - { - $this->saveConnection($connection); - - $connection->send(json_encode([ - 'event' => 'pusher_internal:subscription_succeeded', - 'channel' => $this->channelName, - ])); - } - - public function unsubscribe(ConnectionInterface $connection) - { - unset($this->subscribedConnections[$connection->socketId]); - - if (! $this->hasConnections()) { - DashboardLogger::vacated($connection, $this->channelName); - } - } - - protected function saveConnection(ConnectionInterface $connection) - { - $hadConnectionsPreviously = $this->hasConnections(); - - $this->subscribedConnections[$connection->socketId] = $connection; - - if (! $hadConnectionsPreviously) { - DashboardLogger::occupied($connection, $this->channelName); - } - - DashboardLogger::subscribed($connection, $this->channelName); - } - - public function broadcast($payload) - { - foreach ($this->subscribedConnections as $connection) { - $connection->send(json_encode($payload)); - } - } - - public function broadcastToOthers(ConnectionInterface $connection, $payload) - { - $this->broadcastToEveryoneExcept($payload, $connection->socketId); - } - - public function broadcastToEveryoneExcept($payload, ?string $socketId = null) - { - if (is_null($socketId)) { - return $this->broadcast($payload); - } - - foreach ($this->subscribedConnections as $connection) { - if ($connection->socketId !== $socketId) { - $connection->send(json_encode($payload)); - } - } - } - - public function toArray(): array - { - 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 cacff7efac..0000000000 --- a/src/WebSockets/Channels/ChannelManager.php +++ /dev/null @@ -1,18 +0,0 @@ -channels[$appId][$channelName])) { - $channelClass = $this->determineChannelClass($channelName); - - $this->channels[$appId][$channelName] = new $channelClass($channelName); - } - - return $this->channels[$appId][$channelName]; - } - - public function find(string $appId, string $channelName): ?Channel - { - return $this->channels[$appId][$channelName] ?? null; - } - - 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; - } - - public function getChannels(string $appId): array - { - return $this->channels[$appId] ?? []; - } - - public function getConnectionCount(string $appId): int - { - return collect($this->getChannels($appId)) - ->flatMap(function (Channel $channel) { - return collect($channel->getSubscribedConnections())->pluck('socketId'); - }) - ->unique() - ->count(); - } - - public function removeFromAllChannels(ConnectionInterface $connection) - { - if (! isset($connection->app)) { - return; - } - - /* - * Remove the connection from all channels. - */ - collect(Arr::get($this->channels, $connection->app->id, []))->each->unsubscribe($connection); - - /* - * Unset all channels that have no connections so we don't leak memory. - */ - 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]); - } - } -} diff --git a/src/WebSockets/Channels/PresenceChannel.php b/src/WebSockets/Channels/PresenceChannel.php deleted file mode 100644 index ac13bcfc66..0000000000 --- a/src/WebSockets/Channels/PresenceChannel.php +++ /dev/null @@ -1,124 +0,0 @@ - - */ - protected $users = []; - - /** - * List of sockets keyed by their ID with the value pointing to a user ID. - * - * @var array - */ - protected $sockets = []; - - public function subscribe(ConnectionInterface $connection, stdClass $payload) - { - $this->verifySignature($connection, $payload); - - $this->saveConnection($connection); - - $channelData = json_decode($payload->channel_data, true); - - // The ID of the user connecting - $userId = (string) $channelData['user_id']; - - // Check if the user was already connected to the channel before storing the connection in the state - $userFirstConnection = ! isset($this->users[$userId]); - - // Add or replace the user info in the state - $this->users[$userId] = $channelData['user_info'] ?? []; - - // Add the socket ID to user ID map in the state - $this->sockets[$connection->socketId] = $userId; - - // Send the success event - $connection->send(json_encode([ - 'event' => 'pusher_internal:subscription_succeeded', - 'channel' => $this->channelName, - 'data' => json_encode($this->getChannelData()), - ])); - - // 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. - if ($userFirstConnection) { - $this->broadcastToOthers($connection, [ - 'event' => 'pusher_internal:member_added', - 'channel' => $this->channelName, - 'data' => json_encode($channelData), - ]); - } - } - - public function unsubscribe(ConnectionInterface $connection) - { - parent::unsubscribe($connection); - - if (! isset($this->sockets[$connection->socketId])) { - return; - } - - // Find the user ID belonging to this socket - $userId = $this->sockets[$connection->socketId]; - - // Remove the socket from the state - unset($this->sockets[$connection->socketId]); - - // Test if the user still has open sockets to this channel - $userHasOpenConnections = (array_flip($this->sockets)[$userId] ?? null) !== null; - - // 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. - if (! $userHasOpenConnections) { - $this->broadcastToOthers($connection, [ - 'event' => 'pusher_internal:member_removed', - 'channel' => $this->channelName, - 'data' => json_encode([ - 'user_id' => $userId, - ]), - ]); - - // Remove the user info from the state - unset($this->users[$userId]); - } - } - - protected function getChannelData(): array - { - return [ - 'presence' => [ - 'ids' => array_keys($this->users), - 'hash' => $this->users, - 'count' => count($this->users), - ], - ]; - } - - public function getUsers(): array - { - return $this->users; - } - - public function toArray(): array - { - return array_merge(parent::toArray(), [ - 'user_count' => count($this->users), - ]); - } -} diff --git a/src/WebSockets/Channels/PrivateChannel.php b/src/WebSockets/Channels/PrivateChannel.php deleted file mode 100644 index a4d40c356e..0000000000 --- a/src/WebSockets/Channels/PrivateChannel.php +++ /dev/null @@ -1,16 +0,0 @@ -verifySignature($connection, $payload); - - parent::subscribe($connection, $payload); - } -} diff --git a/src/WebSockets/Exceptions/ConnectionsOverCapacity.php b/src/WebSockets/Exceptions/ConnectionsOverCapacity.php deleted file mode 100644 index 9b0522f5a5..0000000000 --- a/src/WebSockets/Exceptions/ConnectionsOverCapacity.php +++ /dev/null @@ -1,16 +0,0 @@ -message = 'Over capacity'; - - // @See https://pusher.com/docs/pusher_protocol#error-codes - // Indicates an error resulting in the connection - // being closed by Pusher, and that the client may reconnect after 1s or more. - $this->code = 4100; - } -} diff --git a/src/WebSockets/Exceptions/InvalidConnection.php b/src/WebSockets/Exceptions/InvalidConnection.php deleted file mode 100644 index 9a80077bc6..0000000000 --- a/src/WebSockets/Exceptions/InvalidConnection.php +++ /dev/null @@ -1,13 +0,0 @@ -message = 'Invalid Connection'; - - $this->code = 4009; - } -} diff --git a/src/WebSockets/Exceptions/InvalidSignature.php b/src/WebSockets/Exceptions/InvalidSignature.php deleted file mode 100644 index 71f87a17f4..0000000000 --- a/src/WebSockets/Exceptions/InvalidSignature.php +++ /dev/null @@ -1,13 +0,0 @@ -message = 'Invalid Signature'; - - $this->code = 4009; - } -} diff --git a/src/WebSockets/Exceptions/UnknownAppKey.php b/src/WebSockets/Exceptions/UnknownAppKey.php deleted file mode 100644 index 6fe5c83765..0000000000 --- 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/Exceptions/WebSocketException.php b/src/WebSockets/Exceptions/WebSocketException.php deleted file mode 100644 index 5c83cca21e..0000000000 --- a/src/WebSockets/Exceptions/WebSocketException.php +++ /dev/null @@ -1,19 +0,0 @@ - 'pusher:error', - 'data' => [ - 'message' => $this->getMessage(), - 'code' => $this->getCode(), - ], - ]; - } -} diff --git a/src/WebSockets/Messages/PusherChannelProtocolMessage.php b/src/WebSockets/Messages/PusherChannelProtocolMessage.php deleted file mode 100644 index 5217faa2c0..0000000000 --- a/src/WebSockets/Messages/PusherChannelProtocolMessage.php +++ /dev/null @@ -1,65 +0,0 @@ -payload = $payload; - - $this->connection = $connection; - - $this->channelManager = $channelManager; - } - - 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()); - } - } - - /* - * @link https://pusher.com/docs/pusher_protocol#ping-pong - */ - protected function ping(ConnectionInterface $connection) - { - $connection->send(json_encode([ - 'event' => 'pusher:pong', - ])); - } - - /* - * @link https://pusher.com/docs/pusher_protocol#pusher-subscribe - */ - protected function subscribe(ConnectionInterface $connection, stdClass $payload) - { - $channel = $this->channelManager->findOrCreate($connection->app->id, $payload->channel); - - $channel->subscribe($connection, $payload); - } - - public function unsubscribe(ConnectionInterface $connection, stdClass $payload) - { - $channel = $this->channelManager->findOrCreate($connection->app->id, $payload->channel); - - $channel->unsubscribe($connection); - } -} diff --git a/src/WebSockets/Messages/PusherClientMessage.php b/src/WebSockets/Messages/PusherClientMessage.php deleted file mode 100644 index f7c4c4557a..0000000000 --- a/src/WebSockets/Messages/PusherClientMessage.php +++ /dev/null @@ -1,47 +0,0 @@ -payload = $payload; - - $this->connection = $connection; - - $this->channelManager = $channelManager; - } - - public function respond() - { - if (! Str::startsWith($this->payload->event, 'client-')) { - return; - } - - if (! $this->connection->app->clientMessagesEnabled) { - return; - } - - DashboardLogger::clientMessage($this->connection, $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/PusherMessage.php b/src/WebSockets/Messages/PusherMessage.php deleted file mode 100644 index bed95507b4..0000000000 --- a/src/WebSockets/Messages/PusherMessage.php +++ /dev/null @@ -1,8 +0,0 @@ -channelManager = $channelManager; - } - - public function onOpen(ConnectionInterface $connection) - { - $this - ->verifyAppKey($connection) - ->limitConcurrentConnections($connection) - ->generateSocketId($connection) - ->establishConnection($connection); - } - - public function onMessage(ConnectionInterface $connection, MessageInterface $message) - { - $message = PusherMessageFactory::createForMessage($message, $connection, $this->channelManager); - - $message->respond(); - - StatisticsLogger::webSocketMessage($connection); - } - - public function onClose(ConnectionInterface $connection) - { - $this->channelManager->removeFromAllChannels($connection); - - DashboardLogger::disconnection($connection); - - StatisticsLogger::disconnection($connection); - } - - public function onError(ConnectionInterface $connection, Exception $exception) - { - if ($exception instanceof WebSocketException) { - $connection->send(json_encode( - $exception->getPayload() - )); - } - } - - protected function verifyAppKey(ConnectionInterface $connection) - { - $appKey = QueryParameters::create($connection->httpRequest)->get('appKey'); - - if (! $app = App::findByKey($appKey)) { - throw new UnknownAppKey($appKey); - } - - $connection->app = $app; - - return $this; - } - - 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(); - } - } - - return $this; - } - - protected function generateSocketId(ConnectionInterface $connection) - { - $socketId = sprintf('%d.%d', random_int(1, 1000000000), random_int(1, 1000000000)); - - $connection->socketId = $socketId; - - return $this; - } - - protected function establishConnection(ConnectionInterface $connection) - { - $connection->send(json_encode([ - 'event' => 'pusher:connection_established', - 'data' => json_encode([ - 'socket_id' => $connection->socketId, - 'activity_timeout' => 30, - ]), - ])); - - DashboardLogger::connection($connection); - - StatisticsLogger::connection($connection); - - return $this; - } -} diff --git a/src/WebSocketsServiceProvider.php b/src/WebSocketsServiceProvider.php index 1e30ed352b..d31f0f2f12 100644 --- a/src/WebSocketsServiceProvider.php +++ b/src/WebSocketsServiceProvider.php @@ -2,88 +2,180 @@ namespace BeyondCode\LaravelWebSockets; -use BeyondCode\LaravelWebSockets\Apps\AppProvider; +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\Queue\AsyncRedisConnector; use BeyondCode\LaravelWebSockets\Server\Router; -use BeyondCode\LaravelWebSockets\Statistics\Http\Controllers\WebSocketStatisticsEntriesController; -use BeyondCode\LaravelWebSockets\Statistics\Http\Middleware\Authorize as AuthorizeStatistics; -use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager; -use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\ArrayChannelManager; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; class WebSocketsServiceProvider extends ServiceProvider { + /** + * Boot the service provider. + * + * @return void + */ 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 - ->registerRoutes() - ->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->registerStatistics(); + + $this->registerDashboard(); + + $this->registerCommands(); } + /** + * Register the service provider. + * + * @return void + */ public function register() { - $this->mergeConfigFrom(__DIR__.'/../config/websockets.php', 'websockets'); + // + } - $this->app->singleton('websockets.router', function () { - return new Router(); + /** + * Register the async, non-blocking Redis queue driver. + * + * @return void + */ + protected function registerAsyncRedisQueueDriver() + { + Queue::extend('async-redis', function () { + return new AsyncRedisConnector($this->app['redis']); + }); + } + + /** + * Register the statistics-related contracts. + * + * @return void + */ + protected function registerStatistics() + { + $this->app->singleton(StatisticsStore::class, function () { + $class = config('websockets.statistics.store'); + + return new $class; }); - $this->app->singleton(ChannelManager::class, function () { - return config('websockets.channel_manager') !== null && class_exists(config('websockets.channel_manager')) - ? app(config('websockets.channel_manager')) : new ArrayChannelManager(); + $this->app->singleton(StatisticsCollector::class, function () { + $replicationMode = config('websockets.replication.mode', 'local'); + + $class = config("websockets.replication.modes.{$replicationMode}.collector"); + + return new $class; }); + } - $this->app->singleton(AppProvider::class, function () { - return app(config('websockets.app_provider')); + /** + * 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; }); } - protected function registerRoutes() + /** + * Register the managers for the app. + * + * @return void + */ + protected function registerManagers() { - Route::prefix(config('websockets.path'))->group(function () { - Route::middleware(config('websockets.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::middleware(AuthorizeStatistics::class)->group(function () { - Route::post('statistics', [WebSocketStatisticsEntriesController::class, 'store']); - }); + $this->app->singleton(Contracts\AppManager::class, function () { + return $this->app->make(config('websockets.managers.app')); }); + } - return $this; + /** + * Register the dashboard routes. + * + * @return void + */ + protected function registerDashboardRoutes() + { + Route::group([ + 'domain' => config('websockets.dashboard.domain'), + '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'); + }); } + /** + * Register the dashboard gate. + * + * @return void + */ protected function registerDashboardGate() { Gate::define('viewWebSocketsDashboard', function ($user = null) { - return app()->environment('local'); + return $this->app->environment('local'); }); - - return $this; } } diff --git a/tests/AsyncRedisQueueTest.php b/tests/AsyncRedisQueueTest.php new file mode 100644 index 0000000000..89db9cd4f5 --- /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/ChannelTest.php b/tests/Channels/ChannelTest.php deleted file mode 100644 index 56c6a7bc44..0000000000 --- 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, [ - '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/PresenceChannelTest.php b/tests/Channels/PresenceChannelTest.php deleted file mode 100644 index ac5bc45b7f..0000000000 --- a/tests/Channels/PresenceChannelTest.php +++ /dev/null @@ -1,153 +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() - { - $connection = $this->getWebSocketConnection(); - - $this->pusherServer->onOpen($connection); - - $channelData = [ - 'user_id' => 1, - 'user_info' => [ - 'name' => 'Marcel', - ], - ]; - - $message = $this->getSignedSubscribeMessage($connection, 'presence-channel', $channelData); - - $this->pusherServer->onMessage($connection, $message); - - $connection->assertSentEvent('pusher_internal:subscription_succeeded', [ - 'channel' => 'presence-channel', - ]); - } - - /** @test */ - public function clients_with_no_user_info_can_join_presence_channels() - { - $connection = $this->getWebSocketConnection(); - - $this->pusherServer->onOpen($connection); - - $channelData = [ - 'user_id' => 1, - ]; - - $message = $this->getSignedSubscribeMessage($connection, 'presence-channel', $channelData); - - $this->pusherServer->onMessage($connection, $message); - - $connection->assertSentEvent('pusher_internal:subscription_succeeded', [ - 'channel' => 'presence-channel', - ]); - } - - /** @test */ - public function multiple_clients_with_same_user_id_are_counted_once() - { - $this->pusherServer->onOpen($connection = $this->getWebSocketConnection()); - $this->pusherServer->onOpen($connection2 = $this->getWebSocketConnection()); - - $channelName = 'presence-channel'; - $channelData = [ - 'user_id' => $userId = 'user:1', - ]; - - $this->pusherServer->onMessage($connection, $this->getSignedSubscribeMessage($connection, $channelName, $channelData)); - $this->pusherServer->onMessage($connection2, $this->getSignedSubscribeMessage($connection2, $channelName, $channelData)); - - $connection2->assertSentEvent('pusher_internal:subscription_succeeded', [ - 'channel' => $channelName, - 'data' => json_encode([ - 'presence' => [ - 'ids' => [$userId], - 'hash' => [ - $userId => [], - ], - 'count' => 1, - ], - ]), - ]); - } - - /** @test */ - public function multiple_clients_with_same_user_id_trigger_member_added_and_removed_event_only_on_first_and_last_socket_connection() - { - $channelName = 'presence-channel'; - - // Connect the `observer` user to the server - $this->pusherServer->onOpen($observerConnection = $this->getWebSocketConnection()); - $this->pusherServer->onMessage($observerConnection, $this->getSignedSubscribeMessage($observerConnection, $channelName, ['user_id' => 'observer'])); - - // Connect the first socket for user `user:1` to the server - $this->pusherServer->onOpen($firstConnection = $this->getWebSocketConnection()); - $this->pusherServer->onMessage($firstConnection, $this->getSignedSubscribeMessage($firstConnection, $channelName, ['user_id' => 'user:1'])); - - // Make sure the observer sees a `member_added` event for `user:1` - $observerConnection->assertSentEvent('pusher_internal:member_added'); - $observerConnection->resetEvents(); - - // Connect the second socket for user `user:1` to the server - $this->pusherServer->onOpen($secondConnection = $this->getWebSocketConnection()); - $this->pusherServer->onMessage($secondConnection, $this->getSignedSubscribeMessage($secondConnection, $channelName, ['user_id' => 'user: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 `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 `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'); - } - - private function getSignedSubscribeMessage(Connection $connection, string $channelName, array $channelData): Message - { - $signature = "{$connection->socketId}:{$channelName}:".json_encode($channelData); - - return new Message(json_encode([ - 'event' => 'pusher:subscribe', - 'data' => [ - 'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret), - 'channel' => $channelName, - 'channel_data' => json_encode($channelData), - ], - ])); - } -} diff --git a/tests/Channels/PrivateChannelTest.php b/tests/Channels/PrivateChannelTest.php deleted file mode 100644 index 6b8d9b644e..0000000000 --- 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 72e8e9d5bb..0000000000 --- a/tests/ClientProviders/AppTest.php +++ /dev/null @@ -1,34 +0,0 @@ -markTestAsPassed(); - } - - /** @test */ - public function it_will_not_accept_an_empty_appKey() - { - $this->expectException(InvalidApp::class); - - new App(1, '', 'appSecret', 'new'); - } - - /** @test */ - public function it_will_not_accept_an_empty_appSecret() - { - $this->expectException(InvalidApp::class); - - new App(1, 'appKey', '', 'new'); - } -} diff --git a/tests/ClientProviders/ConfigAppProviderTest.php b/tests/ClientProviders/ConfigAppProviderTest.php deleted file mode 100644 index 150233bba5..0000000000 --- a/tests/ClientProviders/ConfigAppProviderTest.php +++ /dev/null @@ -1,88 +0,0 @@ -configAppProvider = new ConfigAppProvider(); - } - - /** @test */ - public function it_can_get_apps_from_the_config_file() - { - $apps = $this->configAppProvider->all(); - - $this->assertCount(1, $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->configAppProvider->findById(0000); - - $this->assertNull($app); - - $app = $this->configAppProvider->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->configAppProvider->findByKey('InvalidKey'); - - $this->assertNull($app); - - $app = $this->configAppProvider->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->configAppProvider->findBySecret('InvalidSecret'); - - $this->assertNull($app); - - $app = $this->configAppProvider->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 91f7790904..0000000000 --- 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 0000000000..8ea2802fb1 --- /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 e80748aaa8..0000000000 --- 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 0000000000..08f71a3083 --- /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/StatisticsCleanTest.php b/tests/Commands/StatisticsCleanTest.php new file mode 100644 index 0000000000..ea236b966b --- /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 89db69413a..2e4f2ed0d2 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -1,68 +1,128 @@ expectException(UnknownAppKey::class); - $this->pusherServer->onOpen($this->getWebSocketConnection('/?appKey=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->pusherServer->onOpen($connection); + $this->newActiveConnection(['public-channel'], 'NonWorkingKey'); - $connection->assertSentEvent('pusher:connection_established'); + $this->assertCount(0, $this->statisticsCollector->getStatistics()); } - /** @test */ - public function app_can_not_exceed_maximum_capacity() + public function test_origin_validation_should_fail_for_no_origin() { - $this->app['config']->set('websockets.apps.0.capacity', 2); + $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->getConnectedWebSocketConnection(['test-channel']); - $this->getConnectedWebSocketConnection(['test-channel']); - $this->expectException(ConnectionsOverCapacity::class); - $this->getConnectedWebSocketConnection(['test-channel']); + $this->pusherServer->onOpen($connection); } - /** @test */ - public function successful_connections_have_the_app_attached() + public function test_origin_validation_should_pass_for_the_right_origin() { - $connection = $this->getWebSocketConnection(); + $connection = $this->newConnection('TestOrigin', ['Origin' => 'https://test.origin.com']); $this->pusherServer->onOpen($connection); - $this->assertInstanceOf(App::class, $connection->app); - $this->assertSame(1234, $connection->app->id); - $this->assertSame('TestKey', $connection->app->key); - $this->assertSame('TestSecret', $connection->app->secret); - $this->assertSame('Test App', $connection->app->name); + $connection->assertSentEvent('pusher:connection_established'); + } + + 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); + }); } - /** @test */ - public function ping_returns_pong() + public function test_websocket_exceptions_are_sent() { - $connection = $this->getWebSocketConnection(); + $connection = $this->newActiveConnection(['public-channel']); - $message = new Message('{"event": "pusher:ping"}'); + $this->pusherServer->onError($connection, new UnknownAppKey('NonWorkingKey')); - $this->pusherServer->onOpen($connection); + $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->newActiveConnection(['test-channel']); + $this->newActiveConnection(['test-channel']); + + $failedConnection = $this->newActiveConnection(['test-channel']); + + $failedConnection + ->assertSentEvent('pusher:error', ['data' => ['message' => 'Over capacity', 'code' => 4100]]) + ->assertClosed(); + } + + public function test_close_all_new_connections_after_stating_the_server_does_not_accept_new_connections() + { + $this->newActiveConnection(['test-channel']) + ->assertSentEvent('pusher:connection_established') + ->assertSentEvent('pusher_internal:subscription_succeeded'); + + $this->channelManager->declineNewConnections(); - $this->pusherServer->onMessage($connection, $message); + $this->assertFalse( + $this->channelManager->acceptsNewConnections() + ); - $connection->assertSentEvent('pusher:pong'); + $this->newActiveConnection(['test-channel']) + ->assertNothingSent() + ->assertClosed(); } } diff --git a/tests/Dashboard/AuthTest.php b/tests/Dashboard/AuthTest.php new file mode 100644 index 0000000000..bc67361a05 --- /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 0000000000..d25d1e0196 --- /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 0000000000..64cd8872e3 --- /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 0000000000..fe5ab50cba --- /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 63% rename from tests/HttpApi/FetchChannelTest.php rename to tests/FetchChannelTest.php index 8324d9e24f..9e4dd64191 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 @@ public function it_returns_the_channel_information() $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchChannelController::class); + $controller = app(FetchChannel::class); $controller->onOpen($connection, $request); @@ -66,26 +65,25 @@ public function it_returns_the_channel_information() ], json_decode($response->getContent(), true)); } - /** @test */ - public function it_returns_the_channel_information_for_presence_channel() + public function test_it_returns_presence_channel_information() { - $this->joinPresenceChannel('presence-global', 'user:1'); - $this->joinPresenceChannel('presence-global', 'user:2'); - $this->joinPresenceChannel('presence-global', 'user:2'); + $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + $this->newPresenceConnection('presence-channel', ['user_id' => 2]); + + $connection = new Mocks\Connection; - $connection = new Connection(); + $requestPath = '/apps/1234/channel/my-channel'; - $requestPath = '/apps/1234/channel/presence-global'; $routeParams = [ 'appId' => '1234', - 'channelName' => 'presence-global', + '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 = app(FetchChannel::class); $controller->onOpen($connection, $request); @@ -94,22 +92,22 @@ public function it_returns_the_channel_information_for_presence_channel() $this->assertSame([ 'occupied' => true, - 'subscription_count' => 3, + 'subscription_count' => 2, 'user_count' => 2, ], 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', @@ -119,7 +117,7 @@ public function it_returns_404_for_invalid_channels() $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 66% rename from tests/HttpApi/FetchChannelsTest.php rename to tests/FetchChannelsTest.php index 0cf5a55e40..b0b08c4cee 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->joinPresenceChannel('presence-channel'); + $this->newPresenceConnection('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); @@ -64,17 +66,17 @@ public function it_returns_the_channel_information() ], 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->joinPresenceChannel('presence-global.1'); - $this->joinPresenceChannel('presence-global.1'); - $this->joinPresenceChannel('presence-global.2'); - $this->joinPresenceChannel('presence-notglobal.2'); + $this->newPresenceConnection('presence-global.1'); + $this->newPresenceConnection('presence-global.1'); + $this->newPresenceConnection('presence-global.2'); + $this->newPresenceConnection('presence-notglobal.2'); - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channels'; + $routeParams = [ 'appId' => '1234', ]; @@ -85,7 +87,7 @@ public function it_returns_the_channel_information_for_prefix() $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchChannelsController::class); + $controller = app(FetchChannels::class); $controller->onOpen($connection, $request); @@ -100,17 +102,17 @@ public function it_returns_the_channel_information_for_prefix() ], 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->joinPresenceChannel('presence-global.1', 'user:1'); - $this->joinPresenceChannel('presence-global.1', 'user:2'); - $this->joinPresenceChannel('presence-global.2', 'user:3'); - $this->joinPresenceChannel('presence-notglobal.2', 'user:4'); + $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]); - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channels'; + $routeParams = [ 'appId' => '1234', ]; @@ -122,7 +124,7 @@ public function it_returns_the_channel_information_for_prefix_with_user_count() $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchChannelsController::class); + $controller = app(FetchChannels::class); $controller->onOpen($connection, $request); @@ -141,15 +143,15 @@ public function it_returns_the_channel_information_for_prefix_with_user_count() ], 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', ]; @@ -160,7 +162,7 @@ public function can_not_get_non_presence_channel_user_count() $request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams)); - $controller = app(FetchChannelsController::class); + $controller = app(FetchChannels::class); $controller->onOpen($connection, $request); @@ -168,12 +170,12 @@ public function can_not_get_non_presence_channel_user_count() $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() { - $connection = new Connection(); + $connection = new Mocks\Connection; $requestPath = '/apps/1234/channels'; + $routeParams = [ 'appId' => '1234', ]; @@ -182,7 +184,7 @@ public function it_returns_empty_object_for_no_channels_found() $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 0000000000..0a5fc09a15 --- /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 0000000000..61da8efe8c --- /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/FetchUsersTest.php b/tests/HttpApi/FetchUsersTest.php deleted file mode 100644 index 43bc858b27..0000000000 --- a/tests/HttpApi/FetchUsersTest.php +++ /dev/null @@ -1,119 +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->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 0000000000..fa643e4211 --- /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 a97aed70d4..0000000000 --- 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 b7c812d8c6..e4d6e1fca1 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); @@ -45,8 +102,16 @@ public function assertSentEvent(string $name, array $additionalParameters = []) 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); @@ -54,10 +119,31 @@ public function assertNotSentEvent(string $name) 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 new file mode 100644 index 0000000000..539e7db413 --- /dev/null +++ b/tests/Mocks/LazyClient.php @@ -0,0 +1,328 @@ +loop = $loop; + $this->redis = Redis::connection(); + } + + /** + * {@inheritdoc} + */ + public function __call($name, $args) + { + $this->calls[] = [$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 + ); + } + + /** + * {@inheritdoc} + */ + public function on($event, callable $listener) + { + $this->events[] = $event; + + return parent::on($event, $listener); + } + + /** + * Check if the method got called. + * + * @param string $name + * @return $this + */ + public function assertCalled($name) + { + foreach ($this->getCalledFunctions() as $function) { + [$calledName, ] = $function; + + if ($calledName === $name) { + PHPUnit::assertTrue(true); + + return $this; + } + } + + PHPUnit::assertFalse(true); + + 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. + * + * @param string $name + * @param array $args + * @return $this + */ + public function assertCalledWithArgs(string $name, array $args) + { + foreach ($this->getCalledFunctions() as $function) { + [$calledName, $calledArgs] = $function; + + if ($calledName === $name && $calledArgs === $args) { + PHPUnit::assertTrue(true); + + return $this; + } + } + + PHPUnit::assertFalse(true); + + 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(string $name) + { + foreach ($this->getCalledFunctions() as $function) { + [$calledName, ] = $function; + + if ($calledName === $name) { + PHPUnit::assertFalse(true); + + return $this; + } + } + + PHPUnit::assertTrue(true); + + return $this; + } + + /** + * Check if the method got not called with specific args. + * + * @param string $name + * @param array $args + * @return $this + */ + public function assertNotCalledWithArgs(string $name, array $args) + { + foreach ($this->getCalledFunctions() as $function) { + [$calledName, $calledArgs] = $function; + + if ($calledName === $name && $calledArgs === $args) { + PHPUnit::assertFalse(true); + + return $this; + } + } + + PHPUnit::assertTrue(true); + + 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. + * + * @return $this + */ + public function assertNothingCalled() + { + PHPUnit::assertEquals([], $this->getCalledFunctions()); + + return $this; + } + + /** + * Check if the event got dispatched. + * + * @param string $event + * @return $this + */ + public function assertEventDispatched($event) + { + foreach ($this->getCalledEvents() as $dispatchedEvent) { + if ($dispatchedEvent === $event) { + PHPUnit::assertTrue(true); + + return $this; + } + } + + PHPUnit::assertFalse(true); + + return $this; + } + + /** + * Check if no function got called. + * + * @return $this + */ + public function assertNothingDispatched() + { + PHPUnit::assertEquals([], $this->getCalledEvents()); + + return $this; + } + + /** + * Get the list of all calls. + * + * @return array + */ + public function getCalledFunctions() + { + return $this->calls; + } + + /** + * Get the list of events. + * + * @return array + */ + public function getCalledEvents() + { + return $this->events; + } + + /** + * Dump the assertions. + * + * @return void + */ + public function dd() + { + dd([ + 'functions' => $this->getCalledFunctions(), + 'events' => $this->getCalledEvents(), + ]); + } + + /** + * Reset the assertions. + * + * @return $this + */ + public function resetAssertions() + { + $this->calls = []; + $this->events = []; + + return $this; + } +} diff --git a/tests/Mocks/Message.php b/tests/Mocks/Message.php index 3b0706c1ad..04a5a1a128 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 0000000000..66f8480565 --- /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 new file mode 100644 index 0000000000..f897f4b07b --- /dev/null +++ b/tests/Mocks/RedisFactory.php @@ -0,0 +1,39 @@ +loop = $loop; + } + + /** + * Create Redis client connected to address of given redis instance. + * + * @param string $target + * @return Client + */ + public function createLazyClient($target) + { + return new LazyClient($target, $this, $this->loop); + } +} diff --git a/tests/Mocks/SignedMessage.php b/tests/Mocks/SignedMessage.php new file mode 100644 index 0000000000..10db94da11 --- /dev/null +++ b/tests/Mocks/SignedMessage.php @@ -0,0 +1,32 @@ +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 0000000000..5ca4c6308e --- /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 0000000000..d983c7802d --- /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 0000000000..90efa6d13d --- /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/PublicChannelTest.php b/tests/PublicChannelTest.php new file mode 100644 index 0000000000..b16498dee7 --- /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 0000000000..14410fb800 --- /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 0000000000..30ef045d66 --- /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/Controllers/WebSocketsStatisticsControllerTest.php b/tests/Statistics/Controllers/WebSocketsStatisticsControllerTest.php deleted file mode 100644 index beede8a797..0000000000 --- a/tests/Statistics/Controllers/WebSocketsStatisticsControllerTest.php +++ /dev/null @@ -1,38 +0,0 @@ -post( - action([WebSocketStatisticsEntriesController::class, 'store']), - array_merge($this->payload(), [ - 'key' => config('websockets.apps.0.key'), - 'secret' => config('websockets.apps.0.secret'), - ]) - ); - - $entries = WebSocketsStatisticsEntry::get(); - - $this->assertCount(1, $entries); - - $this->assertArrayHasKey('app_id', $entries->first()->attributesToArray()); - } - - protected function payload(): array - { - return [ - 'app_id' => config('websockets.apps.0.id'), - 'peak_connection_count' => 1, - 'websocket_message_count' => 2, - 'api_message_count' => 3, - ]; - } -} diff --git a/tests/Statistics/Logger/FakeStatisticsLogger.php b/tests/Statistics/Logger/FakeStatisticsLogger.php deleted file mode 100644 index 1f05bffc2c..0000000000 --- a/tests/Statistics/Logger/FakeStatisticsLogger.php +++ /dev/null @@ -1,23 +0,0 @@ -statistics as $appId => $statistic) { - $currentConnectionCount = $this->channelManager->getConnectionCount($appId); - $statistic->reset($currentConnectionCount); - } - } - - 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 49abd19f00..0000000000 --- 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 0849d0ba6b..0000000000 --- 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 0000000000..419341bbf7 --- /dev/null +++ b/tests/StatisticsStoreTest.php @@ -0,0 +1,95 @@ +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']); + + $this->pusherServer->onClose($rick); + $this->pusherServer->onClose($morty); + + $this->statisticsCollector->save(); + + $this->assertCount(2, $records = $this->statisticsStore->getRecords()); + + $this->assertEquals('2', $records[1]['peak_connections_count']); + + $this->statisticsCollector->save(); + + // The last one should not generate any more records + // since the current state is empty. + $this->assertCount(2, $records = $this->statisticsStore->getRecords()); + } + + 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']); + + $this->pusherServer->onClose($rick); + $this->pusherServer->onClose($morty); + + $this->statisticsCollector->save(); + + $this->assertCount(2, $records = $this->statisticsStore->getRecords()); + + $this->assertEquals('2', $records[1]['peak_connections_count']); + + $this->statisticsCollector->save(); + + // The last one should not generate any more records + // since the current state is empty. + $this->assertCount(2, $records = $this->statisticsStore->getRecords()); + } + + public function test_store_statistics_on_presence_channel() + { + $rick = $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + $morty = $this->newPresenceConnection('presence-channel', ['user_id' => 2]); + $pickleRick = $this->newPresenceConnection('presence-channel', ['user_id' => 1]); + + $this->statisticsCollector->save(); + + $this->assertCount(1, $records = $this->statisticsStore->getRecords()); + + $this->assertEquals('3', $records[0]['peak_connections_count']); + $this->assertEquals('3', $records[0]['websocket_messages_count']); + $this->assertEquals('0', $records[0]['api_messages_count']); + + $this->pusherServer->onClose($rick); + $this->pusherServer->onClose($morty); + $this->pusherServer->onClose($pickleRick); + + $this->statisticsCollector->save(); + + $this->assertCount(2, $records = $this->statisticsStore->getRecords()); + + $this->assertEquals('3', $records[1]['peak_connections_count']); + + $this->statisticsCollector->save(); + + // The last one should not generate any more records + // since the current state is empty. + $this->assertCount(2, $records = $this->statisticsStore->getRecords()); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 39ff08bf00..bcf7e287d6 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,87 +1,345 @@ pusherServer = app(WebSocketHandler::class); + $this->loop = LoopFactory::create(); + + $this->replicationMode = getenv('REPLICATION_MODE') ?: 'local'; - $this->channelManager = app(ChannelManager::class); + $this->resetDatabase(); + $this->loadLaravelMigrations(['--database' => 'sqlite']); + $this->loadMigrationsFrom(__DIR__.'/database/migrations'); + $this->withFactories(__DIR__.'/database/factories'); - StatisticsLogger::swap(new FakeStatisticsLogger( - $this->channelManager, - Mockery::mock(Browser::class) - )); + $this->registerPromiseResolver(); - $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + $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(); + } } + /** + * {@inheritdoc} + */ protected function getPackageProviders($app) { - return [WebSocketsServiceProvider::class]; + return [ + \BeyondCode\LaravelWebSockets\WebSocketsServiceProvider::class, + TestServiceProvider::class, + ]; } - protected function getEnvironmentSetUp($app) + /** + * {@inheritdoc} + */ + 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', - 'id' => 1234, + 'id' => '1234', 'key' => 'TestKey', 'secret' => 'TestSecret', + 'host' => 'localhost', + 'capacity' => null, + 'enable_client_messages' => false, + 'enable_statistics' => true, + 'allowed_origins' => [], + ], + [ + 'name' => 'Origin Test App', + 'id' => '1234', + 'key' => 'TestOrigin', + 'secret' => 'TestSecret', 'capacity' => null, 'enable_client_messages' => false, 'enable_statistics' => true, + 'allowed_origins' => [ + '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('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, ], ]); } - protected function getWebSocketConnection(string $url = '/?appKey=TestKey'): Connection + /** + * Register the test promise resolver. + * + * @return void + */ + protected function registerPromiseResolver() { - $connection = new Connection(); + Helpers::$loop = $this->loop; - $connection->httpRequest = new Request('GET', $url); + $this->app['config']->set( + 'websockets.promise_resolver', + \BeyondCode\LaravelWebSockets\Test\Mocks\PromiseResolver::class + ); + } - return $connection; + /** + * 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); } - protected function getConnectedWebSocketConnection(array $channelsToJoin = [], string $url = '/?appKey=TestKey'): Connection + /** + * Unregister the managers for testing purposes. + * + * @return void + */ + protected function unregisterManagers() { - $connection = new Connection(); + $this->app->offsetUnset(ChannelManager::class); + } - $connection->httpRequest = new Request('GET', $url); + /** + * 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); + + return $connection; + } + + /** + * Get a connected websocket connection. + * + * @param array $channelsToJoin + * @param string $appKey + * @param array $headers + * @return Mocks\Connection + */ + protected function newActiveConnection(array $channelsToJoin = [], string $appKey = 'TestKey', array $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); } @@ -89,42 +347,122 @@ protected function getConnectedWebSocketConnection(array $channelsToJoin = [], s return $connection; } - protected function joinPresenceChannel($channel, $userId = null): Connection + /** + * Join a presence channel. + * + * @param string $channel + * @param array $user + * @param string $appKey + * @param array $headers + * @return Mocks\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_id' => $userId ?? 1, - 'user_info' => [ - 'name' => 'Marcel', - ], + $user = $user ?: [ + 'user_id' => 1, + 'user_info' => ['name' => 'Rick'], ]; - $signature = "{$connection->socketId}:{$channel}:".json_encode($channelData); + $encodedUser = json_encode($user); + + $message = new Mocks\SignedMessage([ + 'event' => 'pusher:subscribe', + 'data' => [ + 'channel' => $channel, + 'channel_data' => $encodedUser, + ], + ], $connection, $channel, $encodedUser); + + $this->pusherServer->onMessage($connection, $message); + + return $connection; + } + + /** + * Join a private channel. + * + * @param string $channel + * @param string $appKey + * @param array $headers + * @return Mocks\Connection + */ + protected function newPrivateConnection($channel, string $appKey = 'TestKey', array $headers = []) + { + $connection = $this->newConnection($appKey, $headers); + + $this->pusherServer->onOpen($connection); - $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), ], - ])); + ], $connection, $channel); $this->pusherServer->onMessage($connection, $message); return $connection; } - protected function getChannel(ConnectionInterface $connection, string $channelName) + /** + * Get the subscribed client for the replication. + * + * @return Mocks\LazyClient + */ + protected function getSubscribeClient() + { + return $this->channelManager->getSubscribeClient(); + } + + /** + * Get the publish client for the replication. + * + * @return Mocks\LazyClient + */ + protected function getPublishClient() + { + return $this->channelManager->getPublishClient(); + } + + /** + * Reset the database. + * + * @return void + */ + protected function resetDatabase() { - return $this->channelManager->findOrCreate($connection->app->id, $channelName); + file_put_contents(__DIR__.'/database.sqlite', null); } - protected function markTestAsPassed() + protected function runOnlyOnRedisReplication() { - $this->assertTrue(true); + 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 0000000000..c43ce4510a --- /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 0000000000..07d6e7b8c7 --- /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 0000000000..0989f288c5 --- /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'); + } +}