This commit is contained in:
Alex Renoki 2020-09-10 22:59:26 +03:00
parent de6b1b28ba
commit 6f32b89459
141 changed files with 4562 additions and 5859 deletions

View File

@ -11,7 +11,7 @@ end_of_line = lf
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
[*.blade.php] [*.{blade.php,yml,yaml}]
indent_size = 2 indent_size = 2
[*.md] [*.md]

65
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,65 @@
name: CI
on:
push:
branches:
- '*'
tags:
- '*'
pull_request:
branches:
- '*'
jobs:
build:
if: "!contains(github.event.head_commit.message, 'skip ci')"
runs-on: ubuntu-latest
strategy:
matrix:
php: ['7.2', '7.3', '7.4']
laravel: ['6.*', '7.*', '8.*']
prefer: ['prefer-lowest', 'prefer-stable']
include:
- laravel: '6.*'
testbench: '4.*'
- laravel: '7.*'
testbench: '5.*'
- laravel: '8.*'
testbench: '6.*'
name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} --${{ matrix.prefer }}
steps:
- uses: actions/checkout@v1
- name: Setup Redis
uses: supercharge/redis-github-action@1.1.0
with:
redis-version: 6
- uses: actions/cache@v1
name: Cache dependencies
with:
path: ~/.composer/cache/files
key: composer-php-${{ matrix.php }}-${{ matrix.laravel }}-${{ matrix.prefer }}-${{ hashFiles('composer.json') }}
- name: Install dependencies
run: |
composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench-browser-kit:${{ matrix.testbench }}" "orchestra/database:${{ matrix.testbench }}" --no-interaction --no-update
composer update --${{ matrix.prefer }} --prefer-dist --no-interaction --no-suggest
- name: Run tests for Local
run: |
REPLICATION_MODE=local phpunit --coverage-text --coverage-clover=coverage_local.xml
- name: Run tests for Redis
run: |
REPLICATION_MODE=redis phpunit --coverage-text --coverage-clover=coverage_redis.xml
- uses: codecov/codecov-action@v1
with:
fail_ci_if_error: false
file: '*.xml'
token: ${{ secrets.CODECOV_TOKEN }}

View File

@ -1,65 +0,0 @@
name: run-tests
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
php: [7.4, 7.3, 7.2]
laravel: [6.*, 7.*]
dependency-version: [prefer-lowest, prefer-stable]
include:
- laravel: 7.*
testbench: 5.*
- laravel: 6.*
testbench: 4.*
name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} - ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v1
- name: Setup Redis
uses: supercharge/redis-github-action@1.1.0
with:
redis-version: 6
if: ${{ matrix.os == 'ubuntu-latest' }}
- name: Cache dependencies
uses: actions/cache@v1
with:
path: ~/.composer/cache/files
key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }}
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick
coverage: xdebug
- name: Install dependencies
run: |
composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench-browser-kit:${{ matrix.testbench }}" --no-interaction --no-update
composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest
- name: Execute tests with Local driver
run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage_local.xml
env:
REPLICATION_DRIVER: local
- name: Execute tests with Redis driver
run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage_redis.xml
if: ${{ matrix.os == 'ubuntu-latest' }}
env:
REPLICATION_DRIVER: redis
- uses: codecov/codecov-action@v1
with:
fail_ci_if_error: false
file: '*.xml'

10
.gitignore vendored
View File

@ -1,7 +1,9 @@
/vendor
/.idea
build build
composer.lock
vendor
coverage
.phpunit.result.cache .phpunit.result.cache
.idea/ coverage
composer.phar
composer.lock
.DS_Store
database.sqlite database.sqlite

View File

@ -1,19 +1,19 @@
filter: filter:
excluded_paths: [tests/*] excluded_paths: [tests/*]
checks: checks:
php: php:
remove_extra_empty_lines: true remove_extra_empty_lines: true
remove_php_closing_tag: true remove_php_closing_tag: true
remove_trailing_whitespace: true remove_trailing_whitespace: true
fix_use_statements: fix_use_statements:
remove_unused: true remove_unused: true
preserve_multiple: false preserve_multiple: false
preserve_blanklines: true preserve_blanklines: true
order_alphabetically: true order_alphabetically: true
fix_php_opening_tag: true fix_php_opening_tag: true
fix_linefeed: true fix_linefeed: true
fix_line_ending: true fix_line_ending: true
fix_identation_4spaces: true fix_identation_4spaces: true
fix_doc_comments: true fix_doc_comments: true

View File

@ -1,4 +1 @@
preset: laravel preset: laravel
disabled:
- single_class_element_per_statement

View File

@ -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

View File

View File

@ -1,12 +1,9 @@
{ {
"name": "beyondcode/laravel-websockets", "name": "beyondcode/laravel-websockets",
"description": "An easy to use WebSocket server", "description": ":package_description",
"keywords": [ "keywords": ["laravel", "php"],
"beyondcode",
"laravel-websockets"
],
"homepage": "https://github.com/beyondcode/laravel-websockets",
"license": "MIT", "license": "MIT",
"homepage": "https://github.com/beyondcode/laravel-websockets",
"authors": [ "authors": [
{ {
"name": "Marcel Pociot", "name": "Marcel Pociot",
@ -19,6 +16,11 @@
"email": "freek@spatie.be", "email": "freek@spatie.be",
"homepage": "https://spatie.be", "homepage": "https://spatie.be",
"role": "Developer" "role": "Developer"
},
{
"name": "Alex Renoki",
"homepage": "https://github.com/rennokki",
"role": "Developer"
} }
], ],
"require": { "require": {
@ -28,50 +30,43 @@
"clue/buzz-react": "^2.5", "clue/buzz-react": "^2.5",
"clue/redis-react": "^2.3", "clue/redis-react": "^2.3",
"evenement/evenement": "^2.0|^3.0", "evenement/evenement": "^2.0|^3.0",
"facade/ignition-contracts": "^1.0",
"guzzlehttp/psr7": "^1.5", "guzzlehttp/psr7": "^1.5",
"illuminate/broadcasting": "^6.0|^7.0", "laravel/framework": "^6.0|^7.0|^8.0",
"illuminate/console": "^6.0|^7.0",
"illuminate/http": "^6.0|^7.0",
"illuminate/routing": "^6.0|^7.0",
"illuminate/support": "^6.0|^7.0",
"pusher/pusher-php-server": "^3.0|^4.0", "pusher/pusher-php-server": "^3.0|^4.0",
"react/promise": "^2.0", "react/promise": "^2.0",
"symfony/http-kernel": "^4.0|^5.0", "symfony/http-kernel": "^4.0|^5.0",
"symfony/psr-http-message-bridge": "^1.1|^2.0" "symfony/psr-http-message-bridge": "^1.1|^2.0"
}, },
"require-dev": {
"clue/block-react": "^1.4",
"mockery/mockery": "^1.3",
"orchestra/testbench-browser-kit": "^4.0|^5.0",
"phpunit/phpunit": "^8.0|^9.0"
},
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"BeyondCode\\LaravelWebSockets\\": "src" "BeyondCode\\LaravelWebSockets\\": "src/"
} }
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
"BeyondCode\\LaravelWebSockets\\Tests\\": "tests" "BeyondCode\\LaravelWebSockets\\Test\\": "tests"
} }
}, },
"scripts": { "scripts": {
"test": "vendor/bin/phpunit", "test": "vendor/bin/phpunit"
"test-coverage": "vendor/bin/phpunit --coverage-html coverage" },
"require-dev": {
"clue/block-react": "^1.4",
"laravel/legacy-factories": "^1.0.4",
"mockery/mockery": "^1.3",
"orchestra/testbench-browser-kit": "^4.0|^5.0|^6.0",
"orchestra/database": "^4.0|^5.0|^6.0",
"phpunit/phpunit": "^8.0|^9.0"
}, },
"config": { "config": {
"sort-packages": true "sort-packages": true
}, },
"minimum-stability": "dev",
"extra": { "extra": {
"laravel": { "laravel": {
"providers": [ "providers": [
"BeyondCode\\LaravelWebSockets\\WebSocketsServiceProvider" "BeyondCode\\LaravelWebSockets\\WebSocketsServiceProvider"
], ]
"aliases": {
"WebSocketRouter": "BeyondCode\\LaravelWebSockets\\Facades\\WebSocketRouter"
}
} }
} }
} }

View File

@ -76,6 +76,141 @@ return [
], ],
], ],
/*
|--------------------------------------------------------------------------
| Broadcasting Replication PubSub
|--------------------------------------------------------------------------
|
| You can enable replication to publish and subscribe to
| messages across the driver.
|
| By default, it is set to 'local', but you can configure it to use drivers
| like Redis to ensure connection between multiple instances of
| WebSocket servers. Just set the driver to 'redis' to enable the PubSub using Redis.
|
*/
'replication' => [
'mode' => env('WEBSOCKETS_REPLICATION_MODE', 'local'),
'modes' => [
/*
|--------------------------------------------------------------------------
| Local Replication
|--------------------------------------------------------------------------
|
| Local replication is actually a null replicator, meaning that it
| is the default behaviour of storing the connections into an array.
|
*/
'local' => [
/*
|--------------------------------------------------------------------------
| Channel Manager
|--------------------------------------------------------------------------
|
| The channel manager is responsible for storing, tracking and retrieving
| the channels as long as their memebers and connections.
|
*/
'channel_manager' => \BeyondCode\LaravelWebSockets\ChannelManagers\LocalChannelManager::class,
/*
|--------------------------------------------------------------------------
| Statistics Collector
|--------------------------------------------------------------------------
|
| The Statistics Collector will, by default, handle the incoming statistics,
| storing them until they will become dumped into another database, usually
| a MySQL database or a time-series database.
|
*/
'collector' => \BeyondCode\LaravelWebSockets\Statistics\Collectors\MemoryCollector::class,
],
'redis' => [
'connection' => 'default',
/*
|--------------------------------------------------------------------------
| Channel Manager
|--------------------------------------------------------------------------
|
| The channel manager is responsible for storing, tracking and retrieving
| the channels as long as their memebers and connections.
|
*/
'channel_manager' => \BeyondCode\LaravelWebSockets\ChannelManagers\RedisChannelManager::class,
/*
|--------------------------------------------------------------------------
| Statistics Collector
|--------------------------------------------------------------------------
|
| The Statistics Collector will, by default, handle the incoming statistics,
| storing them until they will become dumped into another database, usually
| a MySQL database or a time-series database.
|
*/
'collector' => \BeyondCode\LaravelWebSockets\Statistics\Collectors\RedisCollector::class,
],
],
],
'statistics' => [
/*
|--------------------------------------------------------------------------
| Statistics Store
|--------------------------------------------------------------------------
|
| The Statistics Store is the place where all the temporary stats will
| be dumped. This is a much reliable store and will be used to display
| graphs or handle it later on your app.
|
*/
'store' => \BeyondCode\LaravelWebSockets\Statistics\Stores\DatabaseStore::class,
/*
|--------------------------------------------------------------------------
| Statistics Interval Period
|--------------------------------------------------------------------------
|
| Here you can specify the interval in seconds at which
| statistics should be logged.
|
*/
'interval_in_seconds' => 60,
/*
|--------------------------------------------------------------------------
| Statistics Deletion Period
|--------------------------------------------------------------------------
|
| When the clean-command is executed, all recorded statistics older than
| the number of days specified here will be deleted.
|
*/
'delete_statistics_older_than_days' => 60,
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Maximum Request Size | Maximum Request Size
@ -130,130 +265,15 @@ return [
'handlers' => [ 'handlers' => [
'websocket' => \BeyondCode\LaravelWebSockets\WebSockets\WebSocketHandler::class, 'websocket' => \BeyondCode\LaravelWebSockets\Server\WebSocketHandler::class,
'trigger_event' => \BeyondCode\LaravelWebSockets\HttpApi\Controllers\TriggerEventController::class, 'trigger_event' => \BeyondCode\LaravelWebSockets\API\TriggerEvent::class,
'fetch_channels' => \BeyondCode\LaravelWebSockets\HttpApi\Controllers\FetchChannelsController::class, 'fetch_channels' => \BeyondCode\LaravelWebSockets\API\FetchChannels::class,
'fetch_channel' => \BeyondCode\LaravelWebSockets\HttpApi\Controllers\FetchChannelController::class, 'fetch_channel' => \BeyondCode\LaravelWebSockets\API\FetchChannel::class,
'fetch_users' => \BeyondCode\LaravelWebSockets\HttpApi\Controllers\FetchUsersController::class, 'fetch_users' => \BeyondCode\LaravelWebSockets\API\FetchUsers::class,
],
/*
|--------------------------------------------------------------------------
| Broadcasting Replication PubSub
|--------------------------------------------------------------------------
|
| You can enable replication to publish and subscribe to
| messages across the driver.
|
| By default, it is set to 'local', but you can configure it to use drivers
| like Redis to ensure connection between multiple instances of
| WebSocket servers. Just set the driver to 'redis' to enable the PubSub using Redis.
|
*/
'replication' => [
'driver' => env('LARAVEL_WEBSOCKETS_REPLICATION_DRIVER', 'local'),
/*
|--------------------------------------------------------------------------
| Local Replication
|--------------------------------------------------------------------------
|
| Local replication is actually a null replicator, meaning that it
| is the default behaviour of storing the connections into an array.
|
*/
'local' => [
'client' => \BeyondCode\LaravelWebSockets\PubSub\Drivers\LocalClient::class,
'statistics_logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger::class,
'channel_manager' => \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\ArrayChannelManager::class,
],
/*
|--------------------------------------------------------------------------
| Redis Replication
|--------------------------------------------------------------------------
|
| Redis replication relies on the Redis' Pub/Sub protocol. When users
| are connected across multiple nodes, whenever some event gets triggered
| on one instance, the rest of the instances get the same copy and, in
| case the connected users to other instances are valid to receive
| the event, they will receive it.
|
*/
'redis' => [
'connection' => env('LARAVEL_WEBSOCKETS_REPLICATION_CONNECTION', 'default'),
'client' => \BeyondCode\LaravelWebSockets\PubSub\Drivers\RedisClient::class,
'statistics_logger' => \BeyondCode\LaravelWebSockets\Statistics\Logger\RedisStatisticsLogger::class,
'channel_manager' => \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\RedisChannelManager::class,
],
],
'statistics' => [
/*
|--------------------------------------------------------------------------
| Statistics Driver
|--------------------------------------------------------------------------
|
| Here you can specify which driver to use to store the statistics to.
| See down below for each driver's setting.
|
| Available: database
|
*/
'driver' => env('LARAVEL_WEBSOCKETS_STATISTICS_DRIVER', 'database'),
'database' => [
'driver' => \BeyondCode\LaravelWebSockets\Statistics\Drivers\DatabaseDriver::class,
'model' => \BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry::class,
],
/*
|--------------------------------------------------------------------------
| Statistics Interval Period
|--------------------------------------------------------------------------
|
| Here you can specify the interval in seconds at which
| statistics should be logged.
|
*/
'interval_in_seconds' => 60,
/*
|--------------------------------------------------------------------------
| Statistics Deletion Period
|--------------------------------------------------------------------------
|
| When the clean-command is executed, all recorded statistics older than
| the number of days specified here will be deleted.
|
*/
'delete_statistics_older_than_days' => 60,
], ],

View File

@ -16,9 +16,9 @@ class CreateWebSocketsStatisticsEntriesTable extends Migration
Schema::create('websockets_statistics_entries', function (Blueprint $table) { Schema::create('websockets_statistics_entries', function (Blueprint $table) {
$table->increments('id'); $table->increments('id');
$table->string('app_id'); $table->string('app_id');
$table->integer('peak_connection_count'); $table->integer('peak_connections_count');
$table->integer('websocket_message_count'); $table->integer('websocket_messages_count');
$table->integer('api_message_count'); $table->integer('api_messages_count');
$table->nullableTimestamps(); $table->nullableTimestamps();
}); });
} }

View File

@ -10,7 +10,7 @@
processIsolation="false" processIsolation="false"
stopOnFailure="false"> stopOnFailure="false">
<testsuites> <testsuites>
<testsuite name="BeyondCode Test Suite"> <testsuite name="Test Suite">
<directory>tests</directory> <directory>tests</directory>
</testsuite> </testsuite>
</testsuites> </testsuites>
@ -20,6 +20,7 @@
</whitelist> </whitelist>
</filter> </filter>
<php> <php>
<env name="DB_CONNECTION" value="testing"/> <server name="APP_DEBUG" value="1" />
<server name="APP_ENV" value="testing" />
</php> </php>
</phpunit> </phpunit>

View File

@ -395,8 +395,6 @@
let payload = { let payload = {
_token: '{{ csrf_token() }}', _token: '{{ csrf_token() }}',
key: this.app.key,
secret: this.app.secret,
appId: this.app.id, appId: this.app.id,
channel: this.form.channel, channel: this.form.channel,
event: this.form.event, event: this.form.event,
@ -424,10 +422,6 @@
return 'bg-green-700 text-white'; return 'bg-green-700 text-white';
} }
if (log.type === 'vacated') {
return 'bg-orange-500 text-white';
}
if (['disconnection', 'occupied', 'replicator-unsubscribed', 'replicator-left'].includes(log.type)) { if (['disconnection', 'occupied', 'replicator-unsubscribed', 'replicator-left'].includes(log.type)) {
return 'bg-red-700 text-white'; return 'bg-red-700 text-white';
} }

View File

@ -1,11 +1,9 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\HttpApi\Controllers; namespace BeyondCode\LaravelWebSockets\API;
use BeyondCode\LaravelWebSockets\Apps\App; use BeyondCode\LaravelWebSockets\Server\QueryParameters;
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface; use Ratchet\Http\HttpServerInterface;
use BeyondCode\LaravelWebSockets\QueryParameters;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
use Exception; use Exception;
use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\ServerRequest; use GuzzleHttp\Psr7\ServerRequest;
@ -16,10 +14,11 @@ use Illuminate\Support\Collection;
use Psr\Http\Message\RequestInterface; use Psr\Http\Message\RequestInterface;
use Pusher\Pusher; use Pusher\Pusher;
use Ratchet\ConnectionInterface; use Ratchet\ConnectionInterface;
use Ratchet\Http\HttpServerInterface;
use React\Promise\PromiseInterface; use React\Promise\PromiseInterface;
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
use BeyondCode\LaravelWebSockets\Contracts\ChannelManager;
use BeyondCode\LaravelWebSockets\Apps\App;
abstract class Controller implements HttpServerInterface abstract class Controller implements HttpServerInterface
{ {
@ -48,28 +47,19 @@ abstract class Controller implements HttpServerInterface
/** /**
* The channel manager. * The channel manager.
* *
* @var \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager * @var \BeyondCode\LaravelWebSockets\Contracts\ChannelManager
*/ */
protected $channelManager; protected $channelManager;
/**
* The replicator driver.
*
* @var \BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface
*/
protected $replicator;
/** /**
* Initialize the request. * Initialize the request.
* *
* @param \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager $channelManager * @param ChannelManager $channelManager
* @param \BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface $replicator
* @return void * @return void
*/ */
public function __construct(ChannelManager $channelManager, ReplicationInterface $replicator) public function __construct(ChannelManager $channelManager)
{ {
$this->channelManager = $channelManager; $this->channelManager = $channelManager;
$this->replicator = $replicator;
} }
/** /**
@ -202,6 +192,10 @@ abstract class Controller implements HttpServerInterface
return; return;
} }
if ($response instanceof HttpException) {
throw $response;
}
$this->sendAndClose($connection, $response); $this->sendAndClose($connection, $response);
} }
@ -243,11 +237,12 @@ abstract class Controller implements HttpServerInterface
*/ */
protected function ensureValidSignature(Request $request) protected function ensureValidSignature(Request $request)
{ {
/* // The `auth_signature` & `body_md5` parameters are not included when calculating the `auth_signature` value.
* 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.
* The `appId`, `appKey` & `channelName` parameters are actually route parameters and are never supplied by the client.
*/ $params = Arr::except($request->query(), [
$params = Arr::except($request->query(), ['auth_signature', 'body_md5', 'appId', 'appKey', 'channelName']); 'auth_signature', 'body_md5', 'appId', 'appKey', 'channelName',
]);
if ($request->getContent() !== '') { if ($request->getContent() !== '') {
$params['body_md5'] = md5($request->getContent()); $params['body_md5'] = md5($request->getContent());
@ -257,7 +252,9 @@ abstract class Controller implements HttpServerInterface
$signature = "{$request->getMethod()}\n/{$request->path()}\n".Pusher::array_implode('=', '&', $params); $signature = "{$request->getMethod()}\n/{$request->path()}\n".Pusher::array_implode('=', '&', $params);
$authSignature = hash_hmac('sha256', $signature, App::findById($request->get('appId'))->secret); $app = App::findById($request->get('appId'));
$authSignature = hash_hmac('sha256', $signature, $app->secret);
if ($authSignature !== $request->get('auth_signature')) { if ($authSignature !== $request->get('auth_signature')) {
throw new HttpException(401, 'Invalid auth signature provided.'); throw new HttpException(401, 'Invalid auth signature provided.');

52
src/API/FetchChannel.php Normal file
View File

@ -0,0 +1,52 @@
<?php
namespace BeyondCode\LaravelWebSockets\API;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
class FetchChannel extends Controller
{
/**
* Handle the incoming request.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function __invoke(Request $request)
{
$channel = $this->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,
];
});
}
}

80
src/API/FetchChannels.php Normal file
View File

@ -0,0 +1,80 @@
<?php
namespace BeyondCode\LaravelWebSockets\API;
use BeyondCode\LaravelWebSockets\Channels\Channel;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use stdClass;
use Symfony\Component\HttpKernel\Exception\HttpException;
class FetchChannels extends Controller
{
/**
* Handle the incoming request.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function __invoke(Request $request)
{
$attributes = [];
if ($request->has('info')) {
$attributes = explode(',', trim($request->info));
if (in_array('user_count', $attributes) && ! Str::startsWith($request->filter_by_prefix, 'presence-')) {
throw new HttpException(400, 'Request must be limited to presence channels in order to fetch user_count');
}
}
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,
];
});
});
}
}

37
src/API/FetchUsers.php Normal file
View File

@ -0,0 +1,37 @@
<?php
namespace BeyondCode\LaravelWebSockets\API;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\PresenceChannel;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Collection;
use Symfony\Component\HttpKernel\Exception\HttpException;
class FetchUsers extends Controller
{
/**
* Handle the incoming request.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function __invoke(Request $request)
{
if (! Str::startsWith($request->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,
];
});
}
}

67
src/API/TriggerEvent.php Normal file
View File

@ -0,0 +1,67 @@
<?php
namespace BeyondCode\LaravelWebSockets\API;
use BeyondCode\LaravelWebSockets\DashboardLogger;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\PresenceChannel;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Collection;
use Symfony\Component\HttpKernel\Exception\HttpException;
use BeyondCode\LaravelWebSockets\Facades\StatisticsCollector;
class TriggerEvent extends Controller
{
/**
* Handle the incoming request.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function __invoke(Request $request)
{
$channels = $request->channels ?: [];
if (is_string($channels)) {
$channels = [$channels];
}
foreach ($channels as $channelName) {
// Here you can use the ->find(), even if the channel
// does not exist on the server. If it does not exist,
// then the message simply will get broadcasted
// across the other servers.
$channel = $this->channelManager->find(
$request->appId, $channelName
);
$payload = [
'channel' => $channelName,
'event' => $request->name,
'data' => $request->data,
];
if ($channel) {
$channel->broadcastToEveryoneExcept(
(object) $payload,
$request->socket_id,
$request->appId
);
} else {
$this->channelManager->broadcastAcrossServers(
$request->appId, $channelName, (object) $payload
);
}
StatisticsCollector::apiMessage($request->appId);
DashboardLogger::log($request->appId, DashboardLogger::TYPE_API_MESSAGE, [
'channel' => $channelName,
'event' => $request->name,
'payload' => $request->data,
]);
}
return $request->json()->all();
}
}

View File

@ -2,7 +2,7 @@
namespace BeyondCode\LaravelWebSockets\Apps; namespace BeyondCode\LaravelWebSockets\Apps;
use BeyondCode\LaravelWebSockets\Exceptions\InvalidApp; use BeyondCode\LaravelWebSockets\Contracts\AppManager;
class App class App
{ {
@ -76,18 +76,9 @@ class App
* @param string $key * @param string $key
* @param string $secret * @param string $secret
* @return void * @return void
* @throws \BeyondCode\LaravelWebSockets\Exceptions\InvalidApp
*/ */
public function __construct($appId, $appKey, $appSecret) public function __construct($appId, $appKey, $appSecret)
{ {
if ($appKey === '') {
throw InvalidApp::valueIsRequired('appKey', $appId);
}
if ($appSecret === '') {
throw InvalidApp::valueIsRequired('appSecret', $appId);
}
$this->id = $appId; $this->id = $appId;
$this->key = $appKey; $this->key = $appKey;
$this->secret = $appSecret; $this->secret = $appSecret;

View File

@ -2,6 +2,8 @@
namespace BeyondCode\LaravelWebSockets\Apps; namespace BeyondCode\LaravelWebSockets\Apps;
use BeyondCode\LaravelWebSockets\Contracts\AppManager;
class ConfigAppManager implements AppManager class ConfigAppManager implements AppManager
{ {
/** /**
@ -30,7 +32,7 @@ class ConfigAppManager implements AppManager
{ {
return $this->apps return $this->apps
->map(function (array $appAttributes) { ->map(function (array $appAttributes) {
return $this->instantiate($appAttributes); return $this->convertIntoApp($appAttributes);
}) })
->toArray(); ->toArray();
} }
@ -43,11 +45,9 @@ class ConfigAppManager implements AppManager
*/ */
public function findById($appId): ?App public function findById($appId): ?App
{ {
$appAttributes = $this return $this->convertIntoApp(
->apps $this->apps->firstWhere('id', $appId)
->firstWhere('id', $appId); );
return $this->instantiate($appAttributes);
} }
/** /**
@ -58,11 +58,9 @@ class ConfigAppManager implements AppManager
*/ */
public function findByKey($appKey): ?App public function findByKey($appKey): ?App
{ {
$appAttributes = $this return $this->convertIntoApp(
->apps $this->apps->firstWhere('key', $appKey)
->firstWhere('key', $appKey); );
return $this->instantiate($appAttributes);
} }
/** /**
@ -73,11 +71,9 @@ class ConfigAppManager implements AppManager
*/ */
public function findBySecret($appSecret): ?App public function findBySecret($appSecret): ?App
{ {
$appAttributes = $this return $this->convertIntoApp(
->apps $this->apps->firstWhere('secret', $appSecret)
->firstWhere('secret', $appSecret); );
return $this->instantiate($appAttributes);
} }
/** /**
@ -86,7 +82,7 @@ class ConfigAppManager implements AppManager
* @param array|null $app * @param array|null $app
* @return \BeyondCode\LaravelWebSockets\Apps\App|null * @return \BeyondCode\LaravelWebSockets\Apps\App|null
*/ */
protected function instantiate(?array $appAttributes): ?App protected function convertIntoApp(?array $appAttributes): ?App
{ {
if (! $appAttributes) { if (! $appAttributes) {
return null; return null;

View File

@ -0,0 +1,334 @@
<?php
namespace BeyondCode\LaravelWebSockets\ChannelManagers;
use BeyondCode\LaravelWebSockets\Contracts\ChannelManager;
use Illuminate\Support\Str;
use BeyondCode\LaravelWebSockets\Channels\Channel;
use BeyondCode\LaravelWebSockets\Channels\PresenceChannel;
use BeyondCode\LaravelWebSockets\Channels\PrivateChannel;
use React\Promise\FulfilledPromise;
use React\Promise\PromiseInterface;
use stdClass;
use Ratchet\ConnectionInterface;
use React\EventLoop\LoopInterface;
class LocalChannelManager implements ChannelManager
{
/**
* The list of stored channels.
*
* @var array
*/
protected $channels = [];
/**
* The list of users that joined the presence channel.
*
* @var array
*/
protected $users = [];
/**
* Create a new channel manager instance.
*
* @param LoopInterface $loop
* @param string|null $factoryClass
* @return void
*/
public function __construct(LoopInterface $loop, $factoryClass = null)
{
//
}
/**
* 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 all channels for a specific app
* for the current instance.
*
* @param string|int $appId
* @return \React\Promise\PromiseInterface[array]
*/
public function getLocalChannels($appId): PromiseInterface
{
return new FulfilledPromise(
$this->channels[$appId] ?? []
);
}
/**
* Get all channels for a specific app
* across multiple servers.
*
* @param string|int $appId
* @return \React\Promise\PromiseInterface[array]
*/
public function getGlobalChannels($appId): PromiseInterface
{
return $this->getLocalChannels($appId);
}
/**
* Remove connection from all channels.
*
* @param \Ratchet\ConnectionInterface $connection
* @return void
*/
public function unsubscribeFromAllChannels(ConnectionInterface $connection)
{
if (! isset($connection->app)) {
return;
}
$this->getLocalChannels($connection->app->id)
->then(function ($channels) use ($connection) {
collect($channels)->each->unsubscribe($connection);
collect($channels)
->reject->hasConnections()
->each(function (Channel $channel, string $channelName) use ($connection) {
unset($this->channels[$connection->app->id][$channelName]);
});
});
$this->getLocalChannels($connection->app->id)
->then(function ($channels) use ($connection) {
if (count($channels) === 0) {
unset($this->channels[$connection->app->id]);
}
});
}
/**
* Subscribe the connection to a specific channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @param string $channelName
* @param stdClass $payload
* @return void
*/
public function subscribeToChannel(ConnectionInterface $connection, string $channelName, stdClass $payload)
{
$channel = $this->findOrCreate($connection->app->id, $channelName);
$channel->subscribe($connection, $payload);
}
/**
* Unsubscribe the connection from the channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @param string $channelName
* @param stdClass $payload
* @return void
*/
public function unsubscribeFromChannel(ConnectionInterface $connection, string $channelName, stdClass $payload)
{
$channel = $this->findOrCreate($connection->app->id, $channelName);
$channel->unsubscribe($connection, $payload);
}
/**
* Subscribe the connection to a specific channel.
*
* @param string|int $appId
* @return void
*/
public function subscribeToApp($appId)
{
//
}
/**
* Unsubscribe the connection from the channel.
*
* @param string|int $appId
* @return void
*/
public function unsubscribeFromApp($appId)
{
//
}
/**
* Get the connections count on the app
* for the current server instance.
*
* @param string|int $appId
* @param string|null $channelName
* @return \React\Promise\PromiseInterface
*/
public function getLocalConnectionsCount($appId, string $channelName = null): PromiseInterface
{
return $this->getLocalChannels($appId)
->then(function ($channels) use ($channelName) {
return collect($channels)
->when(! is_null($channelName), function ($collection) use ($channelName) {
return $collection->filter(function (Channel $channel) use ($channelName) {
return $channel->getName() === $channelName;
});
})
->flatMap(function (Channel $channel) {
return collect($channel->getConnections())->pluck('socketId');
})
->unique()
->count();
});
}
/**
* Get the connections count
* across multiple servers.
*
* @param string|int $appId
* @param string|null $channelName
* @return \React\Promise\PromiseInterface
*/
public function getGlobalConnectionsCount($appId, string $channelName = null): PromiseInterface
{
return $this->getLocalConnectionsCount($appId, $channelName);
}
/**
* Broadcast the message across multiple servers.
*
* @param string|int $appId
* @param string $channel
* @param stdClass $payload
* @return bool
*/
public function broadcastAcrossServers($appId, string $channel, stdClass $payload)
{
return true;
}
/**
* Handle the user when it joined a presence channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @param stdClass $user
* @param string $channel
* @param stdClass $payload
* @return void
*/
public function userJoinedPresenceChannel(ConnectionInterface $connection, stdClass $user, string $channel, stdClass $payload)
{
$this->users["{$connection->app->id}:{$channel}"][$connection->socketId] = json_encode($user);
}
/**
* Handle the user when it left a presence channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @param stdClass $user
* @param string $channel
* @param stdClass $payload
* @return void
*/
public function userLeftPresenceChannel(ConnectionInterface $connection, stdClass $user, string $channel)
{
unset($this->users["{$connection->app->id}:{$channel}"][$connection->socketId]);
}
/**
* Get the presence channel members.
*
* @param string|int $appId
* @param string $channel
* @return \React\Promise\PromiseInterface
*/
public function getChannelMembers($appId, string $channel): PromiseInterface
{
$members = $this->users["{$appId}:{$channel}"] ?? [];
$members = collect($members)->map(function ($user) {
return json_decode($user);
})->toArray();
return new FulfilledPromise($members);
}
/**
* Get a member from a presence channel based on connection.
*
* @param \Ratchet\ConnectionInterface $connection
* @param string $channel
* @return \React\Promise\PromiseInterface
*/
public function getChannelMember(ConnectionInterface $connection, string $channel): PromiseInterface
{
$member = $this->users["{$connection->app->id}:{$channel}"][$connection->socketId] ?? null;
return new FulfilledPromise($member);
}
/**
* Get the presence channels total members count.
*
* @param string|int $appId
* @param array $channelNames
* @return \React\Promise\PromiseInterface
*/
public function getChannelsMembersCount($appId, array $channelNames): PromiseInterface
{
$results = collect($channelNames)
->reduce(function ($results, $channel) use ($appId) {
$results[$channel] = isset($this->users["{$appId}:{$channel}"])
? count($this->users["{$appId}:{$channel}"])
: 0;
return $results;
}, []);
return new FulfilledPromise($results);
}
/**
* Get the channel class by the channel name.
*
* @param string $channelName
* @return string
*/
protected function getChannelClassName(string $channelName): string
{
if (Str::startsWith($channelName, 'private-')) {
return PrivateChannel::class;
}
if (Str::startsWith($channelName, 'presence-')) {
return PresenceChannel::class;
}
return Channel::class;
}
}

View File

@ -0,0 +1,548 @@
<?php
namespace BeyondCode\LaravelWebSockets\ChannelManagers;
use BeyondCode\LaravelWebSockets\Contracts\ChannelManager;
use Illuminate\Support\Str;
use BeyondCode\LaravelWebSockets\Channels\Channel;
use BeyondCode\LaravelWebSockets\Channels\PresenceChannel;
use BeyondCode\LaravelWebSockets\Channels\PrivateChannel;
use React\Promise\FulfilledPromise;
use React\Promise\PromiseInterface;
use Clue\React\Redis\Client;
use Clue\React\Redis\Factory;
use stdClass;
use Ratchet\ConnectionInterface;
use React\EventLoop\LoopInterface;
class RedisChannelManager extends LocalChannelManager
{
/**
* The running loop.
*
* @var LoopInterface
*/
protected $loop;
/**
* The unique server identifier.
*
* @var string
*/
protected $serverId;
/**
* The pub client.
*
* @var Client
*/
protected $publishClient;
/**
* The sub client.
*
* @var Client
*/
protected $subscribeClient;
/**
* Create a new channel manager instance.
*
* @param LoopInterface $loop
* @param string|null $factoryClass
* @return void
*/
public function __construct(LoopInterface $loop, $factoryClass = null)
{
$this->loop = $loop;
$connectionUri = $this->getConnectionUri();
$factoryClass = $factoryClass ?: Factory::class;
$factory = new $factoryClass($this->loop);
$this->publishClient = $factory->createLazyClient($connectionUri);
$this->subscribeClient = $factory->createLazyClient($connectionUri);
$this->subscribeClient->on('message', function ($channel, $payload) {
$this->onMessage($channel, $payload);
});
$this->serverId = Str::uuid()->toString();
}
/**
* Get all channels for a specific app
* for the current instance.
*
* @param string|int $appId
* @return \React\Promise\PromiseInterface[array]
*/
public function getLocalChannels($appId): PromiseInterface
{
return parent::getLocalChannels($appId);
}
/**
* Get all channels for a specific app
* across multiple servers.
*
* @param string|int $appId
* @return \React\Promise\PromiseInterface[array]
*/
public function getGlobalChannels($appId): PromiseInterface
{
return $this->getPublishClient()->smembers(
$this->getRedisKey($appId, null, ['channels'])
);
}
/**
* Remove connection from all channels.
*
* @param \Ratchet\ConnectionInterface $connection
* @return void
*/
public function unsubscribeFromAllChannels(ConnectionInterface $connection)
{
$this->getGlobalChannels($connection->app->id)
->then(function ($channels) use ($connection) {
foreach ($channels as $channel) {
$this->unsubscribeFromChannel(
$connection, $channel, new stdClass
);
}
});
parent::unsubscribeFromAllChannels($connection);
}
/**
* Subscribe the connection to a specific channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @param string $channelName
* @param stdClass $payload
* @return void
*/
public function subscribeToChannel(ConnectionInterface $connection, string $channelName, stdClass $payload)
{
$this->getGlobalConnectionsCount($connection->app->id, $channelName)
->then(function ($count) use ($connection, $channelName) {
if ($count === 0) {
$this->subscribeToTopic($connection->app->id, $channelName);
}
});
$this->getPublishClient()->sadd(
$this->getRedisKey($connection->app->id, null, ['channels']),
$channelName
);
$this->incrementSubscriptionsCount(
$connection->app->id, $channelName, 1
);
parent::subscribeToChannel($connection, $channelName, $payload);
}
/**
* Unsubscribe the connection from the channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @param string $channelName
* @param stdClass $payload
* @return void
*/
public function unsubscribeFromChannel(ConnectionInterface $connection, string $channelName, stdClass $payload)
{
$this->getGlobalConnectionsCount($connection->app->id, $channelName)
->then(function ($count) use ($connection, $channelName) {
if ($count === 0) {
$this->unsubscribeFromTopic($connection->app->id, $channelName);
$this->getPublishClient()->srem(
$this->getRedisKey($connection->app->id, null, ['channels']),
$channelName
);
return;
}
$increment = $this->incrementSubscriptionsCount(
$connection->app->id, $channelName, -1
)
->then(function ($count) use ($connection, $channelName) {
if ($count < 1) {
$this->unsubscribeFromTopic($connection->app->id, $channelName);
$this->getPublishClient()->srem(
$this->getRedisKey($connection->app->id, null, ['channels']),
$channelName
);
}
});
});
parent::unsubscribeFromChannel($connection, $channelName, $payload);
}
/**
* Subscribe the connection to a specific channel.
*
* @param string|int $appId
* @return void
*/
public function subscribeToApp($appId)
{
$this->subscribeToTopic($appId);
$this->incrementSubscriptionsCount($appId);
}
/**
* Unsubscribe the connection from the channel.
*
* @param string|int $appId
* @return void
*/
public function unsubscribeFromApp($appId)
{
$this->unsubscribeFromTopic($appId);
$this->incrementSubscriptionsCount($appId, null, -1);
}
/**
* Get the connections count on the app
* for the current server instance.
*
* @param string|int $appId
* @param string|null $channelName
* @return \React\Promise\PromiseInterface
*/
public function getLocalConnectionsCount($appId, string $channelName = null): PromiseInterface
{
return parent::getLocalConnectionsCount($appId, $channelName);
}
/**
* Get the connections count
* across multiple servers.
*
* @param string|int $appId
* @param string|null $channelName
* @return \React\Promise\PromiseInterface
*/
public function getGlobalConnectionsCount($appId, string $channelName = null): PromiseInterface
{
return $this->publishClient
->hget($this->getRedisKey($appId, $channelName, ['stats']), 'connections')
->then(function ($count) {
return is_null($count) ? 0 : (int) $count;
});
}
/**
* Broadcast the message across multiple servers.
*
* @param string|int $appId
* @param string $channel
* @param stdClass $payload
* @return bool
*/
public function broadcastAcrossServers($appId, string $channel, stdClass $payload)
{
$payload->appId = $appId;
$payload->serverId = $this->getServerId();
$this->publishClient->publish($this->getRedisKey($appId, $channel), json_encode($payload));
return true;
}
/**
* Handle the user when it joined a presence channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @param stdClass $user
* @param string $channel
* @param stdClass $payload
* @return void
*/
public function userJoinedPresenceChannel(ConnectionInterface $connection, stdClass $user, string $channel, stdClass $payload)
{
$this->storeUserData(
$connection->app->id, $channel, $connection->socketId, json_encode($user)
);
}
/**
* Handle the user when it left a presence channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @param stdClass $user
* @param string $channel
* @param stdClass $payload
* @return void
*/
public function userLeftPresenceChannel(ConnectionInterface $connection, stdClass $user, string $channel)
{
$this->removeUserData(
$connection->app->id, $channel, $connection->socketId
);
}
/**
* Get the presence channel members.
*
* @param string|int $appId
* @param string $channel
* @return \React\Promise\PromiseInterface
*/
public function getChannelMembers($appId, string $channel): PromiseInterface
{
return $this->publishClient
->hgetall($this->getRedisKey($appId, $channel, ['users']))
->then(function ($members) {
[$keys, $values] = collect($members)->partition(function ($value, $key) {
return $key % 2 === 0;
});
return collect(array_combine($keys->all(), $values->all()))
->map(function ($user) {
return json_decode($user);
})
->toArray();
});
}
/**
* Get a member from a presence channel based on connection.
*
* @param \Ratchet\ConnectionInterface $connection
* @param string $channel
* @return \React\Promise\PromiseInterface
*/
public function getChannelMember(ConnectionInterface $connection, string $channel): PromiseInterface
{
return $this->publishClient->hget(
$this->getRedisKey($connection->app->id, $channel, ['users']), $connection->socketId
);
}
/**
* Get the presence channels total members count.
*
* @param string|int $appId
* @param array $channelNames
* @return \React\Promise\PromiseInterface
*/
public function getChannelsMembersCount($appId, array $channelNames): PromiseInterface
{
$this->publishClient->multi();
foreach ($channelNames as $channel) {
$this->publishClient->hlen(
$this->getRedisKey($appId, $channel, ['users'])
);
}
return $this->publishClient
->exec()
->then(function ($data) use ($channelNames) {
return array_combine($channelNames, $data);
});
}
/**
* Handle a message received from Redis on a specific channel.
*
* @param string $redisChannel
* @param string $payload
* @return void
*/
public function onMessage(string $redisChannel, string $payload)
{
$payload = json_decode($payload);
if (isset($payload->serverId) && $this->getServerId() === $payload->serverId) {
return;
}
$payload->channel = Str::after($redisChannel, "{$payload->appId}:");
if (! $channel = $this->find($payload->appId, $payload->channel)) {
return;
}
$appId = $payload->appId ?? null;
$socketId = $payload->socketId ?? null;
$serverId = $payload->serverId ?? null;
unset($payload->socketId);
unset($payload->serverId);
unset($payload->appId);
$channel->broadcastToEveryoneExcept($payload, $socketId, $appId, false);
}
/**
* Build the Redis connection URL from Laravel database config.
*
* @return string
*/
protected function getConnectionUri()
{
$name = config('websockets.replication.redis.connection', 'default');
$config = config("database.redis.{$name}");
$host = $config['host'];
$port = $config['port'] ?: 6379;
$query = [];
if ($config['password']) {
$query['password'] = $config['password'];
}
if ($config['database']) {
$query['database'] = $config['database'];
}
$query = http_build_query($query);
return "redis://{$host}:{$port}".($query ? "?{$query}" : '');
}
/**
* Get the Subscribe client instance.
*
* @return Client
*/
public function getSubscribeClient()
{
return $this->subscribeClient;
}
/**
* Get the Publish client instance.
*
* @return Client
*/
public function getPublishClient()
{
return $this->publishClient;
}
/**
* Get the unique identifier for the server.
*
* @return string
*/
public function getServerId()
{
return $this->serverId;
}
/**
* Increment the subscribed count number.
*
* @param string|int $appId
* @param string|null $channel
* @param int $increment
* @return PromiseInterface
*/
public function incrementSubscriptionsCount($appId, string $channel = null, int $increment = 1)
{
return $this->publishClient->hincrby(
$this->getRedisKey($appId, $channel, ['stats']), 'connections', $increment
);
}
/**
* Set data for a topic. Might be used for the presence channels.
*
* @param string|int $appId
* @param string|null $channel
* @param string $key
* @param mixed $data
* @return PromiseInterface
*/
public function storeUserData($appId, string $channel = null, string $key, $data)
{
$this->publishClient->hset(
$this->getRedisKey($appId, $channel, ['users']), $key, $data
);
}
/**
* Remove data for a topic. Might be used for the presence channels.
*
* @param string|int $appId
* @param string|null $channel
* @param string $key
* @return PromiseInterface
*/
public function removeUserData($appId, string $channel = null, string $key)
{
return $this->publishClient->hdel(
$this->getRedisKey($appId, $channel), $key
);
}
/**
* Subscribe to the topic for the app, or app and channel.
*
* @param string|int $appId
* @param string|null $channel
* @return void
*/
public function subscribeToTopic($appId, string $channel = null)
{
$this->subscribeClient->subscribe(
$this->getRedisKey($appId, $channel)
);
}
/**
* Unsubscribe from the topic for the app, or app and channel.
*
* @param string|int $appId
* @param string|null $channel
* @return void
*/
public function unsubscribeFromTopic($appId, string $channel = null)
{
$this->subscribeClient->unsubscribe(
$this->getRedisKey($appId, $channel)
);
}
/**
* Get the Redis Keyspace name to handle subscriptions
* and other key-value sets.
*
* @param mixed $appId
* @param string|null $channel
* @return string
*/
public function getRedisKey($appId, string $channel = null, array $suffixes = []): string
{
$prefix = config('database.redis.options.prefix', null);
$hash = "{$prefix}{$appId}";
if ($channel) {
$hash .= ":{$channel}";
}
$suffixes = join(':', $suffixes);
if ($suffixes) {
$hash .= $suffixes;
}
return $hash;
}
}

190
src/Channels/Channel.php Normal file
View File

@ -0,0 +1,190 @@
<?php
namespace BeyondCode\LaravelWebSockets\Channels;
use BeyondCode\LaravelWebSockets\DashboardLogger;
use Ratchet\ConnectionInterface;
use stdClass;
use BeyondCode\LaravelWebSockets\Contracts\ChannelManager;
use Illuminate\Support\Str;
use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature;
class Channel
{
/**
* The channel name.
*
* @var string
*/
protected $name;
/**
* The connections that got subscribed to this channel.
*
* @var array
*/
protected $connections = [];
/**
* Create a new instance.
*
* @param string $name
* @return void
*/
public function __construct(string $name)
{
$this->name = $name;
$this->channelManager = app(ChannelManager::class);
}
/**
* Get channel name.
*
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* Get the list of subscribed connections.
*
* @return array
*/
public function getConnections()
{
return $this->connections;
}
/**
* Check if the channel has connections.
*
* @return bool
*/
public function hasConnections(): bool
{
return count($this->getConnections()) > 0;
}
/**
* Add a new connection to the channel.
*
* @see https://pusher.com/docs/pusher_protocol#presence-channel-events
* @param \Ratchet\ConnectionInterface $connection
* @param \stdClass $payload
* @return void
*/
public function subscribe(ConnectionInterface $connection, stdClass $payload)
{
$this->saveConnection($connection);
$connection->send(json_encode([
'event' => 'pusher_internal:subscription_succeeded',
'channel' => $this->getName(),
]));
DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_SUBSCRIBED, [
'socketId' => $connection->socketId,
'channel' => $this->getName(),
]);
}
/**
* Unsubscribe connection from the channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @return void
*/
public function unsubscribe(ConnectionInterface $connection)
{
if (! isset($this->connections[$connection->socketId])) {
return;
}
unset($this->connections[$connection->socketId]);
}
/**
* Store the connection to the subscribers list.
*
* @param \Ratchet\ConnectionInterface $connection
* @return void
*/
protected function saveConnection(ConnectionInterface $connection)
{
$this->connections[$connection->socketId] = $connection;
}
/**
* Broadcast a payload to the subscribed connections.
*
* @param string|int $appId
* @param \stdClass $payload
* @param bool $replicate
* @return bool
*/
public function broadcast($appId, stdClass $payload, bool $replicate = true): bool
{
collect($this->getConnections())
->each->send(json_encode($payload));
if ($replicate) {
$this->channelManager->broadcastAcrossServers($appId, $this->getName(), $payload);
}
return true;
}
/**
* Broadcast the payload, but exclude a specific socket id.
*
* @param \stdClass $payload
* @param string|null $socketId
* @param string|int $appId
* @param bool $replicate
* @return bool
*/
public function broadcastToEveryoneExcept(stdClass $payload, ?string $socketId, $appId, bool $replicate = true)
{
if ($replicate) {
$this->channelManager->broadcastAcrossServers($appId, $this->getName(), $payload);
}
if (is_null($socketId)) {
return $this->broadcast($appId, $payload, $replicate);
}
collect($this->getConnections())->each(function (ConnectionInterface $connection) use ($socketId, $payload) {
if ($connection->socketId !== $socketId) {
$connection->send(json_encode($payload));
}
});
return true;
}
/**
* Check if the signature for the payload is valid.
*
* @param \Ratchet\ConnectionInterface $connection
* @param \stdClass $payload
* @return void
* @throws InvalidSignature
*/
protected function verifySignature(ConnectionInterface $connection, stdClass $payload)
{
$signature = "{$connection->socketId}:{$this->getName()}";
if (isset($payload->channel_data)) {
$signature .= ":{$payload->channel_data}";
}
if (! hash_equals(
hash_hmac('sha256', $signature, $connection->app->secret),
Str::after($payload->auth, ':'))
) {
throw new InvalidSignature;
}
}
}

View File

@ -0,0 +1,139 @@
<?php
namespace BeyondCode\LaravelWebSockets\Channels;
use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature;
use Ratchet\ConnectionInterface;
use stdClass;
class PresenceChannel extends PrivateChannel
{
/**
* Subscribe to the channel.
*
* @see https://pusher.com/docs/pusher_protocol#presence-channel-events
* @param \Ratchet\ConnectionInterface $connection
* @param \stdClass $payload
* @return void
* @throws InvalidSignature
*/
public function subscribe(ConnectionInterface $connection, stdClass $payload)
{
parent::subscribe($connection, $payload);
$this->channelManager->userJoinedPresenceChannel(
$connection,
$user = json_decode($payload->channel_data),
$this->getName(),
$payload
);
$this->channelManager
->getChannelMembers($connection->app->id, $this->getName())
->then(function ($users) use ($connection) {
$connection->send(json_encode([
'event' => 'pusher_internal:subscription_succeeded',
'channel' => $this->getName(),
'data' => json_encode($this->getChannelData($users)),
]));
});
$memberAddedPayload = [
'event' => 'pusher_internal:member_added',
'channel' => $this->getName(),
'data' => $payload->channel_data,
];
$this->broadcastToEveryoneExcept(
(object) $memberAddedPayload, $connection->socketId,
$connection->app->id
);
}
/**
* Unsubscribe connection from the channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @return void
*/
public function unsubscribe(ConnectionInterface $connection)
{
parent::unsubscribe($connection);
$this->channelManager
->getChannelMember($connection, $this->getName())
->then(function ($user) use ($connection) {
$user = @json_decode($user);
if (! $user) {
return;
}
$this->channelManager->userLeftPresenceChannel(
$connection, $user, $this->getName()
);
$memberRemovedPayload = [
'event' => 'pusher_internal:member_removed',
'channel' => $this->getName(),
'data' => json_encode([
'user_id' => $user->user_id,
]),
];
$this->broadcastToEveryoneExcept(
(object) $memberRemovedPayload, $connection->socketId,
$connection->app->id
);
});
}
/**
* Get the Presence channel data.
*
* @param array $users
* @return array
*/
protected function getChannelData(array $users): array
{
return [
'presence' => [
'ids' => $this->getUserIds($users),
'hash' => $this->getHash($users),
'count' => count($users),
],
];
}
/**
* Get the Presence Channel's users.
*
* @param array $users
* @return array
*/
protected function getUserIds(array $users): array
{
return collect($users)
->map(function ($user) {
return (string) $user->user_id;
})
->values();
}
/**
* Compute the hash for the presence channel integrity.
*
* @param array $users
* @return array
*/
protected function getHash(array $users): array
{
$hash = [];
foreach ($users as $socketId => $user) {
$hash[$user->user_id] = $user->user_info ?? [];
}
return $hash;
}
}

View File

@ -1,8 +1,8 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Channels; namespace BeyondCode\LaravelWebSockets\Channels;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\InvalidSignature; use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature;
use Ratchet\ConnectionInterface; use Ratchet\ConnectionInterface;
use stdClass; use stdClass;

View File

@ -1,8 +1,8 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\Console; namespace BeyondCode\LaravelWebSockets\Console\Commands;
use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver; use BeyondCode\LaravelWebSockets\Facades\StatisticsStore;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class CleanStatistics extends Command class CleanStatistics extends Command
@ -14,6 +14,7 @@ class CleanStatistics extends Command
*/ */
protected $signature = 'websockets:clean protected $signature = 'websockets:clean
{appId? : (optional) The app id that will be cleaned.} {appId? : (optional) The app id that will be cleaned.}
{--days= : Delete records older than this amount of days since now.}
'; ';
/** /**
@ -21,20 +22,23 @@ class CleanStatistics extends Command
* *
* @var string|null * @var string|null
*/ */
protected $description = 'Clean up old statistics from the websocket log.'; protected $description = 'Clean up old statistics from the WebSocket statistics storage.';
/** /**
* Run the command. * Run the command.
* *
* @param \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver $driver
* @return void * @return void
*/ */
public function handle(StatisticsDriver $driver) public function handle()
{ {
$this->comment('Cleaning WebSocket Statistics...'); $this->comment('Cleaning WebSocket Statistics...');
$amountDeleted = $driver::delete($this->argument('appId')); $days = $this->option('days') ?: config('statistics.delete_statistics_older_than_days');
$this->info("Deleted {$amountDeleted} record(s) from the WebSocket statistics."); $amountDeleted = StatisticsStore::delete(
now()->subDays($days), $this->argument('appId')
);
$this->info("Deleted {$amountDeleted} record(s) from the WebSocket statistics storage.");
} }
} }

View File

@ -1,12 +1,12 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\Console; namespace BeyondCode\LaravelWebSockets\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\InteractsWithTime; use Illuminate\Support\InteractsWithTime;
class RestartWebSocketServer extends Command class RestartServer extends Command
{ {
use InteractsWithTime; use InteractsWithTime;
@ -22,7 +22,7 @@ class RestartWebSocketServer extends Command
* *
* @var string|null * @var string|null
*/ */
protected $description = 'Restart the Laravel WebSocket Server'; protected $description = 'Signal the WebSockets server to restart.';
/** /**
* Run the command. * Run the command.
@ -31,8 +31,13 @@ class RestartWebSocketServer extends Command
*/ */
public function handle() public function handle()
{ {
Cache::forever('beyondcode:websockets:restart', $this->currentTime()); Cache::forever(
'beyondcode:websockets:restart',
$this->currentTime()
);
$this->info('Broadcasting WebSocket server restart signal.'); $this->info(
'Broadcasted the restart signal to the WebSocket server!'
);
} }
} }

View File

@ -1,22 +1,20 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\Console; namespace BeyondCode\LaravelWebSockets\Console\Commands;
use BeyondCode\LaravelWebSockets\Facades\StatisticsLogger; use BeyondCode\LaravelWebSockets\Server\Loggers\ConnectionLogger;
use BeyondCode\LaravelWebSockets\Facades\WebSocketsRouter; use BeyondCode\LaravelWebSockets\Server\Loggers\HttpLogger;
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface; use BeyondCode\LaravelWebSockets\Server\Loggers\WebSocketsLogger;
use BeyondCode\LaravelWebSockets\Server\Logger\ConnectionLogger; use BeyondCode\LaravelWebSockets\ServerFactory;
use BeyondCode\LaravelWebSockets\Server\Logger\HttpLogger;
use BeyondCode\LaravelWebSockets\Server\Logger\WebsocketsLogger;
use BeyondCode\LaravelWebSockets\Server\WebSocketServerFactory;
use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver;
use BeyondCode\LaravelWebSockets\Statistics\Logger\StatisticsLogger as StatisticsLoggerInterface;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache; use BeyondCode\LaravelWebSockets\Facades\WebSocketsRouter;
use BeyondCode\LaravelWebSockets\Facades\StatisticsCollector as StatisticsCollectorFacade;
use BeyondCode\LaravelWebSockets\Contracts\ChannelManager;
use BeyondCode\LaravelWebSockets\Contracts\StatisticsCollector;
use React\EventLoop\Factory as LoopFactory; use React\EventLoop\Factory as LoopFactory;
use Illuminate\Support\Facades\Cache;
class StartWebSocketServer extends Command class StartServer extends Command
{ {
/** /**
* The name and signature of the console command. * The name and signature of the console command.
@ -26,7 +24,8 @@ class StartWebSocketServer extends Command
protected $signature = 'websockets:serve protected $signature = 'websockets:serve
{--host=0.0.0.0} {--host=0.0.0.0}
{--port=6001} {--port=6001}
{--statistics-interval= : Overwrite the statistics interval set in the config.} {--disable-statistics : Disable the statistics tracking.}
{--statistics-interval= : The amount of seconds to tick between statistics saving.}
{--debug : Forces the loggers to be enabled and thereby overriding the APP_DEBUG setting.} {--debug : Forces the loggers to be enabled and thereby overriding the APP_DEBUG setting.}
{--test : Prepare the server, but do not start it.} {--test : Prepare the server, but do not start it.}
'; ';
@ -36,7 +35,7 @@ class StartWebSocketServer extends Command
* *
* @var string|null * @var string|null
*/ */
protected $description = 'Start the Laravel WebSocket Server'; protected $description = 'Start the LaravelWebSockets server.';
/** /**
* Get the loop instance. * Get the loop instance.
@ -52,13 +51,6 @@ class StartWebSocketServer extends Command
*/ */
public $server; public $server;
/**
* Track the last restart.
*
* @var int
*/
protected $lastRestart;
/** /**
* Initialize the command. * Initialize the command.
* *
@ -78,96 +70,83 @@ class StartWebSocketServer extends Command
*/ */
public function handle() public function handle()
{ {
$this $this->configureLoggers();
->configureStatisticsLogger()
->configureHttpLogger() $this->configureManagers();
->configureMessageLogger()
->configureConnectionLogger() $this->configureStatistics();
->configureRestartTimer()
->configurePubSub() $this->configureRestartTimer();
->registerRoutes()
->startWebSocketServer(); $this->startServer();
} }
/** /**
* Configure the statistics logger class. * Configure the loggers used for the console.
* *
* @return $this * @return void
*/ */
protected function configureStatisticsLogger() protected function configureLoggers()
{ {
$this->laravel->singleton(StatisticsLoggerInterface::class, function () { $this->configureHttpLogger();
$replicationDriver = config('websockets.replication.driver', 'local'); $this->configureMessageLogger();
$this->configureConnectionLogger();
$class = config("websockets.replication.{$replicationDriver}.statistics_logger", \BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger::class);
return new $class(
$this->laravel->make(ChannelManager::class),
$this->laravel->make(StatisticsDriver::class)
);
});
$this->loop->addPeriodicTimer($this->option('statistics-interval') ?: config('websockets.statistics.interval_in_seconds'), function () {
$this->line('Saving statistics...');
StatisticsLogger::save();
});
return $this;
} }
/** /**
* Configure the HTTP logger class. * Register the managers that are not resolved
* in the package service provider.
* *
* @return $this * @return void
*/ */
protected function configureHttpLogger() protected function configureManagers()
{ {
$this->laravel->singleton(HttpLogger::class, function () { $this->laravel->singleton(ChannelManager::class, function () {
return (new HttpLogger($this->output)) $mode = config('websockets.replication.mode', 'local');
->enable($this->option('debug') ?: config('app.debug'))
->verbose($this->output->isVerbose());
});
return $this; $class = config("websockets.replication.modes.{$mode}.channel_manager");
return new $class($this->loop);
});
} }
/** /**
* Configure the logger for messages. * Register the Statistics Collectors that
* are not resolved in the package service provider.
* *
* @return $this * @return void
*/ */
protected function configureMessageLogger() protected function configureStatistics()
{ {
$this->laravel->singleton(WebsocketsLogger::class, function () { $this->laravel->singleton(StatisticsCollector::class, function () {
return (new WebsocketsLogger($this->output)) $replicationMode = config('websockets.replication.mode', 'local');
->enable($this->option('debug') ?: config('app.debug'))
->verbose($this->output->isVerbose()); $class = config("websockets.replication.modes.{$replicationMode}.collector");
return new $class;
}); });
return $this; $this->laravel->singleton(StatisticsStore::class, function () {
$class = config('websockets.statistics.store');
return new $class;
});
if (! $this->option('disable-statistics')) {
$intervalInSeconds = $this->option('statistics-interval') ?: config('websockets.statistics.interval_in_seconds', 3600);
$this->loop->addPeriodicTimer($intervalInSeconds, function () {
$this->line('Saving statistics...');
StatisticsCollectorFacade::save();
});
}
} }
/** /**
* Configure the connection logger. * Configure the restart timer.
* *
* @return $this * @return void
*/
protected function configureConnectionLogger()
{
$this->laravel->bind(ConnectionLogger::class, function () {
return (new ConnectionLogger($this->output))
->enable(config('app.debug'))
->verbose($this->output->isVerbose());
});
return $this;
}
/**
* Configure the Redis PubSub handler.
*
* @return $this
*/ */
public function configureRestartTimer() public function configureRestartTimer()
{ {
@ -178,45 +157,48 @@ class StartWebSocketServer extends Command
$this->loop->stop(); $this->loop->stop();
} }
}); });
return $this;
} }
/** /**
* Configure the replicators. * Configure the HTTP logger class.
* *
* @return void * @return void
*/ */
public function configurePubSub() protected function configureHttpLogger()
{ {
$this->laravel->singleton(ReplicationInterface::class, function () { $this->laravel->singleton(HttpLogger::class, function () {
$driver = config('websockets.replication.driver', 'local'); return (new HttpLogger($this->output))
->enable($this->option('debug') ?: config('app.debug'))
$client = config( ->verbose($this->output->isVerbose());
"websockets.replication.{$driver}.client",
\BeyondCode\LaravelWebSockets\PubSub\Drivers\LocalClient::class
);
return (new $client)->boot($this->loop);
}); });
$this->laravel
->get(ReplicationInterface::class)
->boot($this->loop);
return $this;
} }
/** /**
* Register the routes. * Configure the logger for messages.
* *
* @return $this * @return void
*/ */
protected function registerRoutes() protected function configureMessageLogger()
{ {
WebSocketsRouter::routes(); $this->laravel->singleton(WebSocketsLogger::class, function () {
return (new WebSocketsLogger($this->output))
->enable($this->option('debug') ?: config('app.debug'))
->verbose($this->output->isVerbose());
});
}
return $this; /**
* Configure the connection logger.
*
* @return void
*/
protected function configureConnectionLogger()
{
$this->laravel->bind(ConnectionLogger::class, function () {
return (new ConnectionLogger($this->output))
->enable(config('app.debug'))
->verbose($this->output->isVerbose());
});
} }
/** /**
@ -224,7 +206,7 @@ class StartWebSocketServer extends Command
* *
* @return void * @return void
*/ */
protected function startWebSocketServer() protected function startServer()
{ {
$this->info("Starting the WebSocket server on port {$this->option('port')}..."); $this->info("Starting the WebSocket server on port {$this->option('port')}...");
@ -238,7 +220,6 @@ class StartWebSocketServer extends Command
}); });
} }
/* 🛰 Start the server 🛰 */
$this->server->run(); $this->server->run();
} }
@ -249,13 +230,13 @@ class StartWebSocketServer extends Command
*/ */
protected function buildServer() protected function buildServer()
{ {
$this->server = new WebSocketServerFactory( $this->server = new ServerFactory(
$this->option('host'), $this->option('port') $this->option('host'), $this->option('port')
); );
$this->server = $this->server $this->server = $this->server
->setLoop($this->loop) ->setLoop($this->loop)
->useRoutes(WebSocketsRouter::getRoutes()) ->withRoutes(WebSocketsRouter::getRoutes())
->setConsoleOutput($this->output) ->setConsoleOutput($this->output)
->createServer(); ->createServer();
} }
@ -267,6 +248,8 @@ class StartWebSocketServer extends Command
*/ */
protected function getLastRestart() protected function getLastRestart()
{ {
return Cache::get('beyondcode:websockets:restart', 0); return Cache::get(
'beyondcode:websockets:restart', 0
);
} }
} }

View File

@ -1,6 +1,8 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\Apps; namespace BeyondCode\LaravelWebSockets\Contracts;
use BeyondCode\LaravelWebSockets\Apps\App;
interface AppManager interface AppManager
{ {

View File

@ -0,0 +1,180 @@
<?php
namespace BeyondCode\LaravelWebSockets\Contracts;
use Ratchet\ConnectionInterface;
use React\Promise\PromiseInterface;
use stdClass;
use React\EventLoop\LoopInterface;
interface ChannelManager
{
/**
* Create a new channel manager instance.
*
* @param LoopInterface $loop
* @param string|null $factoryClass
* @return void
*/
public function __construct(LoopInterface $loop, $factoryClass = null);
/**
* 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);
/**
* 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);
/**
* 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;
/**
* Get all channels for a specific app
* across multiple servers.
*
* @param string|int $appId
* @return \React\Promise\PromiseInterface[array]
*/
public function getGlobalChannels($appId): PromiseInterface;
/**
* Remove connection from all channels.
*
* @param \Ratchet\ConnectionInterface $connection
* @return void
*/
public function unsubscribeFromAllChannels(ConnectionInterface $connection);
/**
* Subscribe the connection to a specific channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @param string $channelName
* @param stdClass $payload
* @return void
*/
public function subscribeToChannel(ConnectionInterface $connection, string $channelName, stdClass $payload);
/**
* Unsubscribe the connection from the channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @param string $channelName
* @param stdClass $payload
* @return void
*/
public function unsubscribeFromChannel(ConnectionInterface $connection, string $channelName, stdClass $payload);
/**
* Subscribe the connection to a specific channel.
*
* @param string|int $appId
* @return void
*/
public function subscribeToApp($appId);
/**
* Unsubscribe the connection from the channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @param string|int $appId
* @return void
*/
public function unsubscribeFromApp($appId);
/**
* Get the connections count on the app
* for the current server instance.
*
* @param string|int $appId
* @param string|null $channelName
* @return \React\Promise\PromiseInterface
*/
public function getLocalConnectionsCount($appId, string $channelName = null): PromiseInterface;
/**
* Get the connections count
* across multiple servers.
*
* @param string|int $appId
* @param string|null $channelName
* @return \React\Promise\PromiseInterface
*/
public function getGlobalConnectionsCount($appId, string $channelName = null): PromiseInterface;
/**
* Broadcast the message across multiple servers.
*
* @param string|int $appId
* @param string $channel
* @param stdClass $payload
* @return bool
*/
public function broadcastAcrossServers($appId, string $channel, stdClass $payload);
/**
* Handle the user when it joined a presence channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @param stdClass $user
* @param string $channel
* @param stdClass $payload
* @return void
*/
public function userJoinedPresenceChannel(ConnectionInterface $connection, stdClass $user, string $channel, stdClass $payload);
/**
* Handle the user when it left a presence channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @param stdClass $user
* @param string $channel
* @param stdClass $payload
* @return void
*/
public function userLeftPresenceChannel(ConnectionInterface $connection, stdClass $user, string $channel);
/**
* Get the presence channel members.
*
* @param string|int $appId
* @param string $channel
* @return \React\Promise\PromiseInterface
*/
public function getChannelMembers($appId, string $channel): PromiseInterface;
/**
* 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;
/**
* 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;
}

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Messages; namespace BeyondCode\LaravelWebSockets\Contracts;
interface PusherMessage interface PusherMessage
{ {

View File

@ -0,0 +1,70 @@
<?php
namespace BeyondCode\LaravelWebSockets\Contracts;
use React\Promise\FulfilledPromise;
use React\Promise\PromiseInterface;
interface StatisticsCollector
{
/**
* Handle the incoming websocket message.
*
* @param string|int $appId
* @return void
*/
public function webSocketMessage($appId);
/**
* Handle the incoming API message.
*
* @param string|int $appId
* @return void
*/
public function apiMessage($appId);
/**
* Handle the new conection.
*
* @param string|int $appId
* @return void
*/
public function connection($appId);
/**
* Handle disconnections.
*
* @param string|int $appId
* @return void
*/
public function disconnection($appId);
/**
* Save all the stored statistics.
*
* @return void
*/
public function save();
/**
* Flush the stored statistics.
*
* @return void
*/
public function flush();
/**
* Get the saved statistics.
*
* @return PromiseInterface[array]
*/
public function getStatistics(): PromiseInterface;
/**
* Get the saved statistics for an app.
*
* @param string|int $appId
* @return PromiseInterface[\BeyondCode\LaravelWebSockets\Statistics\Statistic|null]
*/
public function getAppStatistics($appId): PromiseInterface;
}

View File

@ -0,0 +1,54 @@
<?php
namespace BeyondCode\LaravelWebSockets\Contracts;
use Carbon\Carbon;
interface StatisticsStore
{
/**
* Store a new record in the database and return
* the created instance.
*
* @param array $data
* @return mixed
*/
public static function store(array $data);
/**
* Delete records older than the given moment,
* for a specific app id (if given), returning
* the amount of deleted records.
*
* @param \Carbon\Carbon $moment
* @param string|int|null $appId
* @return int
*/
public static function delete(Carbon $moment, $appId = null): int;
/**
* Get the query result as eloquent collection.
*
* @param callable $processQuery
* @return \Illuminate\Support\Collection
*/
public function getRawRecords(callable $processQuery = null);
/**
* 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;
/**
* Get the results for a specific query into a
* format that is easily to read for graphs.
*
* @param callable $processQuery
* @return array
*/
public function getForGraph(callable $processQuery = null): array;
}

View File

@ -21,7 +21,7 @@ class AuthenticateDashboard
*/ */
public function __invoke(Request $request) public function __invoke(Request $request)
{ {
$app = App::findById($request->header('x-app-id')); $app = App::findById($request->header('X-App-Id'));
$broadcaster = $this->getPusherBroadcaster([ $broadcaster = $this->getPusherBroadcaster([
'key' => $app->key, 'key' => $app->key,

View File

@ -2,51 +2,53 @@
namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers; namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers;
use BeyondCode\LaravelWebSockets\Concerns\PushesToPusher; use BeyondCode\LaravelWebSockets\Contracts\ChannelManager;
use BeyondCode\LaravelWebSockets\Statistics\Rules\AppId; use BeyondCode\LaravelWebSockets\Rules\AppId;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class SendMessage class SendMessage
{ {
use PushesToPusher;
/** /**
* Send the message to the requested channel. * Send the message to the requested channel.
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param \BeyondCode\LaravelWebSockets\Contracts\ChannelManager $channelManager
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*/ */
public function __invoke(Request $request) public function __invoke(Request $request, ChannelManager $channelManager)
{ {
$request->validate([ $request->validate([
'appId' => ['required', new AppId], 'appId' => ['required', new AppId],
'key' => 'required|string',
'secret' => 'required|string',
'channel' => 'required|string', 'channel' => 'required|string',
'event' => 'required|string', 'event' => 'required|string',
'data' => 'required|json', 'data' => 'required|json',
]); ]);
$broadcaster = $this->getPusherBroadcaster([ $payload = [
'key' => $request->key, 'channel' => $request->channel,
'secret' => $request->secret, 'event' => $request->event,
'id' => $request->appId, 'data' => json_decode($request->data, true),
]); ];
try { // Here you can use the ->find(), even if the channel
$decodedData = @json_decode($request->data, true); // does not exist on the server. If it does not exist,
// then the message simply will get broadcasted
// across the other servers.
$channel = $channelManager->find(
$request->appId, $request->channel
);
$broadcaster->broadcast( if ($channel) {
[$request->channel], $channel->broadcastToEveryoneExcept(
$request->event, (object) $payload,
$decodedData ?: [] null,
$request->appId
);
} else {
$channelManager->broadcastAcrossServers(
$request->appId, $request->channel, (object) $payload
); );
} catch (Exception $e) {
return response()->json([
'ok' => false,
'exception' => $e->getMessage(),
]);
} }
return response()->json([ return response()->json([

View File

@ -2,8 +2,8 @@
namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers; namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers;
use BeyondCode\LaravelWebSockets\Apps\AppManager; use BeyondCode\LaravelWebSockets\Contracts\AppManager;
use BeyondCode\LaravelWebSockets\Dashboard\DashboardLogger; use BeyondCode\LaravelWebSockets\DashboardLogger;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class ShowDashboard class ShowDashboard
@ -12,7 +12,7 @@ class ShowDashboard
* Show the dashboard. * Show the dashboard.
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param \BeyondCode\LaravelWebSockets\Apps\AppManager $apps * @param \BeyondCode\LaravelWebSockets\Contracts\AppManager $apps
* @return void * @return void
*/ */
public function __invoke(Request $request, AppManager $apps) public function __invoke(Request $request, AppManager $apps)

View File

@ -2,7 +2,7 @@
namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers; namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers;
use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver; use BeyondCode\LaravelWebSockets\Facades\StatisticsStore;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class ShowStatistics class ShowStatistics
@ -11,12 +11,23 @@ class ShowStatistics
* Get statistics for an app ID. * Get statistics for an app ID.
* *
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @param \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver $driver
* @param mixed $appId * @param mixed $appId
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*/ */
public function __invoke(Request $request, StatisticsDriver $driver, $appId) public function __invoke(Request $request, $appId)
{ {
return $driver::get($appId, $request); $processQuery = function ($query) use ($appId) {
return $query->whereAppId($appId)
->latest()
->limit(120);
};
$processCollection = function ($collection) {
return $collection->reverse();
};
return StatisticsStore::getForGraph(
$processQuery, $processCollection
);
} }
} }

View File

@ -1,8 +1,8 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\Dashboard; namespace BeyondCode\LaravelWebSockets;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager; use BeyondCode\LaravelWebSockets\Contracts\ChannelManager;
class DashboardLogger class DashboardLogger
{ {
@ -12,7 +12,6 @@ class DashboardLogger
const TYPE_CONNECTED = 'connected'; const TYPE_CONNECTED = 'connected';
const TYPE_VACATED = 'vacated';
const TYPE_OCCUPIED = 'occupied'; const TYPE_OCCUPIED = 'occupied';
@ -42,7 +41,6 @@ class DashboardLogger
public static $channels = [ public static $channels = [
self::TYPE_DISCONNECTED, self::TYPE_DISCONNECTED,
self::TYPE_CONNECTED, self::TYPE_CONNECTED,
self::TYPE_VACATED,
self::TYPE_OCCUPIED, self::TYPE_OCCUPIED,
self::TYPE_SUBSCRIBED, self::TYPE_SUBSCRIBED,
self::TYPE_WS_MESSAGE, self::TYPE_WS_MESSAGE,
@ -65,18 +63,36 @@ class DashboardLogger
*/ */
public static function log($appId, string $type, array $details = []) public static function log($appId, string $type, array $details = [])
{ {
$channelManager = app(ChannelManager::class);
$channelName = static::LOG_CHANNEL_PREFIX.$type; $channelName = static::LOG_CHANNEL_PREFIX.$type;
$channel = app(ChannelManager::class)->find($appId, $channelName); $payload = [
optional($channel)->broadcast([
'event' => 'log-message',
'channel' => $channelName, 'channel' => $channelName,
'event' => 'log-message',
'data' => [ 'data' => [
'type' => $type, 'type' => $type,
'time' => strftime('%H:%M:%S'), 'time' => strftime('%H:%M:%S'),
'details' => $details, 'details' => $details,
], ],
]); ];
// Here you can use the ->find(), even if the channel
// does not exist on the server. If it does not exist,
// then the message simply will get broadcasted
// across the other servers.
$channel = $channelManager->find($appId, $channelName);
if ($channel) {
$channel->broadcastToEveryoneExcept(
(object) $payload,
null,
$appId
);
} else {
$channelManager->broadcastAcrossServers(
$appId, $channelName, (object) $payload
);
}
} }
} }

View File

@ -1,29 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessagesBroadcasted
{
use Dispatchable, SerializesModels;
/**
* The amount of messages sent.
*
* @var int
*/
protected $sentMessagesCount;
/**
* Initialize the event.
*
* @param int $sentMessagesCount
* @return void
*/
public function __construct(int $sentMessagesCount = 0)
{
$this->sentMessagesCount = $sentMessagesCount;
}
}

View File

@ -1,39 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Ratchet\ConnectionInterface;
class Subscribed
{
use Dispatchable, SerializesModels;
/**
* The channel name the user has subscribed to.
*
* @var string
*/
protected $channelName;
/**
* The connection that initiated the subscription.
*
* @var \Ratchet\ConnectionInterface
*/
protected $connection;
/**
* Initialize the event.
*
* @param string $channelName
* @param \Ratchet\ConnectionInterface $connection
* @return void
*/
public function __construct(string $channelName, ConnectionInterface $connection)
{
$this->channelName = $channelName;
$this->connection = $connection;
}
}

View File

@ -1,39 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Ratchet\ConnectionInterface;
class Unsubscribed
{
use Dispatchable, SerializesModels;
/**
* The channel name the user has unsubscribed from.
*
* @var string
*/
protected $channelName;
/**
* The connection that initiated the unsubscription.
*
* @var \Ratchet\ConnectionInterface
*/
protected $connection;
/**
* Initialize the event.
*
* @param string $channelName
* @param \Ratchet\ConnectionInterface $connection
* @return void
*/
public function __construct(string $channelName, ConnectionInterface $connection)
{
$this->channelName = $channelName;
$this->connection = $connection;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace BeyondCode\LaravelWebSockets\Facades;
use Illuminate\Support\Facades\Facade;
use BeyondCode\LaravelWebSockets\Contracts\StatisticsCollector as StatisticsCollectorInterface;
class StatisticsCollector extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return StatisticsCollectorInterface::class;
}
}

View File

@ -1,23 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Facades;
use BeyondCode\LaravelWebSockets\Statistics\Logger\StatisticsLogger as StatisticsLoggerInterface;
use Illuminate\Support\Facades\Facade;
/**
* @see \BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger
* @mixin \BeyondCode\LaravelWebSockets\Statistics\Logger\MemoryStatisticsLogger
*/
class StatisticsLogger extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return StatisticsLoggerInterface::class;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace BeyondCode\LaravelWebSockets\Facades;
use Illuminate\Support\Facades\Facade;
use BeyondCode\LaravelWebSockets\Contracts\StatisticsStore as StatisticsStoreInterface;
class StatisticsStore extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return StatisticsStoreInterface::class;
}
}

View File

@ -4,10 +4,6 @@ namespace BeyondCode\LaravelWebSockets\Facades;
use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Facade;
/**
* @see \BeyondCode\LaravelWebSockets\Server\Router
* @mixin \BeyondCode\LaravelWebSockets\Server\Router
*/
class WebSocketsRouter extends Facade class WebSocketsRouter extends Facade
{ {
/** /**

View File

@ -1,26 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\HttpApi\Controllers;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
class FetchChannelController extends Controller
{
/**
* Handle the incoming request.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function __invoke(Request $request)
{
$channel = $this->channelManager->find($request->appId, $request->channelName);
if (is_null($channel)) {
throw new HttpException(404, "Unknown channel `{$request->channelName}`.");
}
return $channel->toArray($request->appId);
}
}

View File

@ -1,67 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\HttpApi\Controllers;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\PresenceChannel;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use stdClass;
use Symfony\Component\HttpKernel\Exception\HttpException;
class FetchChannelsController extends Controller
{
/**
* Handle the incoming request.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function __invoke(Request $request)
{
$attributes = [];
if ($request->has('info')) {
$attributes = explode(',', trim($request->info));
if (in_array('user_count', $attributes) && ! Str::startsWith($request->filter_by_prefix, 'presence-')) {
throw new HttpException(400, 'Request must be limited to presence channels in order to fetch user_count');
}
}
$channels = Collection::make($this->channelManager->getChannels($request->appId));
if ($request->has('filter_by_prefix')) {
$channels = $channels->filter(function ($channel, $channelName) use ($request) {
return Str::startsWith($channelName, $request->filter_by_prefix);
});
}
// We want to get the channel user count all in one shot when
// using a replication backend rather than doing individual queries.
// To do so, we first collect the list of channel names.
$channelNames = $channels->map(function (PresenceChannel $channel) {
return $channel->getChannelName();
})->toArray();
// We ask the replication backend to get us the member count per channel.
// We get $counts back as a key-value array of channel names and their member count.
return $this->replicator
->channelMemberCounts($request->appId, $channelNames)
->then(function (array $counts) use ($channels, $attributes) {
$channels = $channels->map(function (PresenceChannel $channel) use ($counts, $attributes) {
$info = new stdClass;
if (in_array('user_count', $attributes)) {
$info->user_count = $counts[$channel->getChannelName()];
}
return $info;
})->toArray();
return [
'channels' => $channels ?: new stdClass,
];
});
}
}

View File

@ -1,40 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\HttpApi\Controllers;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\PresenceChannel;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Symfony\Component\HttpKernel\Exception\HttpException;
class FetchUsersController extends Controller
{
/**
* Handle the incoming request.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function __invoke(Request $request)
{
$channel = $this->channelManager->find($request->appId, $request->channelName);
if (is_null($channel)) {
throw new HttpException(404, 'Unknown channel "'.$request->channelName.'"');
}
if (! $channel instanceof PresenceChannel) {
throw new HttpException(400, 'Invalid presence channel "'.$request->channelName.'"');
}
return $channel
->getUsers($request->appId)
->then(function (array $users) {
return [
'users' => Collection::make($users)->map(function ($user) {
return ['id' => $user->user_id];
})->values(),
];
});
}
}

View File

@ -1,57 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\HttpApi\Controllers;
use BeyondCode\LaravelWebSockets\Dashboard\DashboardLogger;
use BeyondCode\LaravelWebSockets\Facades\StatisticsLogger;
use Illuminate\Http\Request;
class TriggerEventController extends Controller
{
/**
* Handle the incoming request.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function __invoke(Request $request)
{
$this->ensureValidSignature($request);
$channels = $request->channels ?: [];
foreach ($channels as $channelName) {
$channel = $this->channelManager->find($request->appId, $channelName);
$payload = (object) [
'channel' => $channelName,
'event' => $request->name,
'data' => $request->data,
];
if ($channel) {
$channel->broadcastToEveryoneExcept(
$payload, $request->socket_id, $request->appId
);
} else {
// If the setup is horizontally-scaled using the Redis Pub/Sub,
// then we're going to make sure it gets streamed to the other
// servers as well that are subscribed to the Pub/Sub topics
// attached to the current iterated app & channel.
// For local setups, the local driver will ignore the publishes.
$this->replicator->publish($request->appId, $channelName, $payload);
}
DashboardLogger::log($request->appId, DashboardLogger::TYPE_API_MESSAGE, [
'channel' => $channelName,
'event' => $request->json()->get('name'),
'payload' => $request->json()->get('data'),
]);
StatisticsLogger::apiMessage($request->appId);
}
return $request->json()->all();
}
}

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\Statistics\Models; namespace BeyondCode\LaravelWebSockets\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -9,10 +9,10 @@ class WebSocketsStatisticsEntry extends Model
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected $guarded = []; protected $table = 'websockets_statistics_entries';
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
protected $table = 'websockets_statistics_entries'; protected $guarded = [];
} }

View File

@ -1,184 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\PubSub\Drivers;
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface;
use React\EventLoop\LoopInterface;
use React\Promise\FulfilledPromise;
use React\Promise\PromiseInterface;
use stdClass;
class LocalClient implements ReplicationInterface
{
/**
* Mapping of the presence JSON data for users in each channel.
*
* @var string[][]
*/
protected $channelData = [];
/**
* Boot the pub/sub provider (open connections, initial subscriptions, etc).
*
* @param LoopInterface $loop
* @param string|null $factoryClass
* @return self
*/
public function boot(LoopInterface $loop, $factoryClass = null): ReplicationInterface
{
return $this;
}
/**
* Publish a payload on a specific channel, for a specific app.
*
* @param string $appId
* @param string $channel
* @param stdClass $payload
* @return bool
*/
public function publish($appId, string $channel, stdClass $payload): bool
{
return true;
}
/**
* Subscribe to receive messages for a channel.
*
* @param string $appId
* @param string $channel
* @return bool
*/
public function subscribe($appId, string $channel): bool
{
return true;
}
/**
* Unsubscribe from a channel.
*
* @param string $appId
* @param string $channel
* @return bool
*/
public function unsubscribe($appId, string $channel): bool
{
return true;
}
/**
* Subscribe to the app's pubsub keyspace.
*
* @param mixed $appId
* @return bool
*/
public function subscribeToApp($appId): bool
{
return true;
}
/**
* Unsubscribe from the app's pubsub keyspace.
*
* @param mixed $appId
* @return bool
*/
public function unsubscribeFromApp($appId): bool
{
return true;
}
/**
* Add a member to a channel. To be called when they have
* subscribed to the channel.
*
* @param string $appId
* @param string $channel
* @param string $socketId
* @param string $data
* @return void
*/
public function joinChannel($appId, string $channel, string $socketId, string $data)
{
$this->channelData["{$appId}:{$channel}"][$socketId] = $data;
}
/**
* Remove a member from the channel. To be called when they have
* unsubscribed from the channel.
*
* @param string $appId
* @param string $channel
* @param string $socketId
* @return void
*/
public function leaveChannel($appId, string $channel, string $socketId)
{
unset($this->channelData["{$appId}:{$channel}"][$socketId]);
if (empty($this->channelData["{$appId}:{$channel}"])) {
unset($this->channelData["{$appId}:{$channel}"]);
}
}
/**
* Retrieve the full information about the members in a presence channel.
*
* @param string $appId
* @param string $channel
* @return PromiseInterface
*/
public function channelMembers($appId, string $channel): PromiseInterface
{
$members = $this->channelData["{$appId}:{$channel}"] ?? [];
$members = array_map(function ($user) {
return json_decode($user);
}, $members);
return new FulfilledPromise($members);
}
/**
* Get the amount of users subscribed for each presence channel.
*
* @param string $appId
* @param array $channelNames
* @return PromiseInterface
*/
public function channelMemberCounts($appId, array $channelNames): PromiseInterface
{
$results = [];
// Count the number of users per channel
foreach ($channelNames as $channel) {
$results[$channel] = isset($this->channelData["{$appId}:{$channel}"])
? count($this->channelData["{$appId}:{$channel}"])
: 0;
}
return new FulfilledPromise($results);
}
/**
* Get the amount of unique connections.
*
* @param mixed $appId
* @return null|int
*/
public function getLocalConnectionsCount($appId)
{
return null;
}
/**
* Get the amount of connections aggregated on multiple instances.
*
* @param mixed $appId
* @return null|int|\React\Promise\PromiseInterface
*/
public function getGlobalConnectionsCount($appId)
{
return null;
}
}

View File

@ -1,437 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\PubSub\Drivers;
use BeyondCode\LaravelWebSockets\Dashboard\DashboardLogger;
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
use Clue\React\Redis\Client;
use Clue\React\Redis\Factory;
use Illuminate\Support\Str;
use React\EventLoop\LoopInterface;
use React\Promise\PromiseInterface;
use stdClass;
class RedisClient extends LocalClient
{
/**
* The running loop.
*
* @var LoopInterface
*/
protected $loop;
/**
* The unique server identifier.
*
* @var string
*/
protected $serverId;
/**
* The pub client.
*
* @var Client
*/
protected $publishClient;
/**
* The sub client.
*
* @var Client
*/
protected $subscribeClient;
/**
* Mapping of subscribed channels, where the key is the channel name,
* and the value is the amount of connections which are subscribed to
* that channel. Used to keep track of whether we still need to stay
* subscribed to those channels with Redis.
*
* @var int[]
*/
protected $subscribedChannels = [];
/**
* Create a new Redis client.
*
* @return void
*/
public function __construct()
{
$this->serverId = Str::uuid()->toString();
}
/**
* Boot the RedisClient, initializing the connections.
*
* @param LoopInterface $loop
* @param string|null $factoryClass
* @return ReplicationInterface
*/
public function boot(LoopInterface $loop, $factoryClass = null): ReplicationInterface
{
$factoryClass = $factoryClass ?: Factory::class;
$this->loop = $loop;
$connectionUri = $this->getConnectionUri();
$factory = new $factoryClass($this->loop);
$this->publishClient = $factory->createLazyClient($connectionUri);
$this->subscribeClient = $factory->createLazyClient($connectionUri);
// The subscribed client gets a message, it triggers the onMessage().
$this->subscribeClient->on('message', function ($channel, $payload) {
$this->onMessage($channel, $payload);
});
return $this;
}
/**
* Publish a message to a channel on behalf of a websocket user.
*
* @param string $appId
* @param string $channel
* @param stdClass $payload
* @return bool
*/
public function publish($appId, string $channel, stdClass $payload): bool
{
$payload->appId = $appId;
$payload->serverId = $this->getServerId();
$payload = json_encode($payload);
$this->publishClient->publish($this->getTopicName($appId, $channel), $payload);
DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_MESSAGE_PUBLISHED, [
'channel' => $channel,
'serverId' => $this->getServerId(),
'payload' => $payload,
'pubsub' => $this->getTopicName($appId, $channel),
]);
return true;
}
/**
* Subscribe to a channel on behalf of websocket user.
*
* @param string $appId
* @param string $channel
* @return bool
*/
public function subscribe($appId, string $channel): bool
{
if (! isset($this->subscribedChannels["{$appId}:{$channel}"])) {
// We're not subscribed to the channel yet, subscribe and set the count to 1
$this->subscribeClient->subscribe($this->getTopicName($appId, $channel));
$this->subscribedChannels["{$appId}:{$channel}"] = 1;
} else {
// Increment the subscribe count if we've already subscribed
$this->subscribedChannels["{$appId}:{$channel}"]++;
}
DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_SUBSCRIBED, [
'channel' => $channel,
'serverId' => $this->getServerId(),
'pubsub' => $this->getTopicName($appId, $channel),
]);
return true;
}
/**
* Unsubscribe from a channel on behalf of a websocket user.
*
* @param string $appId
* @param string $channel
* @return bool
*/
public function unsubscribe($appId, string $channel): bool
{
if (! isset($this->subscribedChannels["{$appId}:{$channel}"])) {
return false;
}
// Decrement the subscription count for this channel
$this->subscribedChannels["{$appId}:{$channel}"]--;
// If we no longer have subscriptions to that channel, unsubscribe
if ($this->subscribedChannels["{$appId}:{$channel}"] < 1) {
$this->subscribeClient->unsubscribe($this->getTopicName($appId, $channel));
unset($this->subscribedChannels["{$appId}:{$channel}"]);
}
DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_UNSUBSCRIBED, [
'channel' => $channel,
'serverId' => $this->getServerId(),
'pubsub' => $this->getTopicName($appId, $channel),
]);
return true;
}
/**
* Subscribe to the app's pubsub keyspace.
*
* @param mixed $appId
* @return bool
*/
public function subscribeToApp($appId): bool
{
$this->subscribeClient->subscribe($this->getTopicName($appId));
$this->publishClient->hincrby($this->getTopicName($appId), 'connections', 1);
return true;
}
/**
* Unsubscribe from the app's pubsub keyspace.
*
* @param mixed $appId
* @return bool
*/
public function unsubscribeFromApp($appId): bool
{
$this->subscribeClient->unsubscribe($this->getTopicName($appId));
$this->publishClient->hincrby($this->getTopicName($appId), 'connections', -1);
return true;
}
/**
* Add a member to a channel. To be called when they have
* subscribed to the channel.
*
* @param string $appId
* @param string $channel
* @param string $socketId
* @param string $data
* @return void
*/
public function joinChannel($appId, string $channel, string $socketId, string $data)
{
$this->publishClient->hset($this->getTopicName($appId, $channel), $socketId, $data);
DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_JOINED_CHANNEL, [
'channel' => $channel,
'serverId' => $this->getServerId(),
'socketId' => $socketId,
'data' => $data,
'pubsub' => $this->getTopicName($appId, $channel),
]);
}
/**
* Remove a member from the channel. To be called when they have
* unsubscribed from the channel.
*
* @param string $appId
* @param string $channel
* @param string $socketId
* @return void
*/
public function leaveChannel($appId, string $channel, string $socketId)
{
$this->publishClient->hdel($this->getTopicName($appId, $channel), $socketId);
DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_LEFT_CHANNEL, [
'channel' => $channel,
'serverId' => $this->getServerId(),
'socketId' => $socketId,
'pubsub' => $this->getTopicName($appId, $channel),
]);
}
/**
* Retrieve the full information about the members in a presence channel.
*
* @param string $appId
* @param string $channel
* @return PromiseInterface
*/
public function channelMembers($appId, string $channel): PromiseInterface
{
return $this->publishClient->hgetall($this->getTopicName($appId, $channel))
->then(function ($members) {
// The data is expected as objects, so we need to JSON decode
return array_map(function ($user) {
return json_decode($user);
}, $members);
});
}
/**
* Get the amount of users subscribed for each presence channel.
*
* @param string $appId
* @param array $channelNames
* @return PromiseInterface
*/
public function channelMemberCounts($appId, array $channelNames): PromiseInterface
{
$this->publishClient->multi();
foreach ($channelNames as $channel) {
$this->publishClient->hlen($this->getTopicName($appId, $channel));
}
return $this->publishClient
->exec()
->then(function ($data) use ($channelNames) {
return array_combine($channelNames, $data);
});
}
/**
* Get the amount of connections aggregated on multiple instances.
*
* @param mixed $appId
* @return null|int|\React\Promise\PromiseInterface
*/
public function getGlobalConnectionsCount($appId)
{
return $this->publishClient->hget($this->getTopicName($appId), 'connections');
}
/**
* Handle a message received from Redis on a specific channel.
*
* @param string $redisChannel
* @param string $payload
* @return void
*/
public function onMessage(string $redisChannel, string $payload)
{
$payload = json_decode($payload);
// Ignore messages sent by ourselves.
if (isset($payload->serverId) && $this->getServerId() === $payload->serverId) {
return;
}
// Pull out the app ID. See RedisPusherBroadcaster
$appId = $payload->appId;
// We need to put the channel name in the payload.
// We strip the app ID from the channel name, websocket clients
// expect the channel name to not include the app ID.
$payload->channel = Str::after($redisChannel, "{$appId}:");
$channelManager = app(ChannelManager::class);
// Load the Channel instance to sync.
$channel = $channelManager->find($appId, $payload->channel);
// If no channel is found, none of our connections want to
// receive this message, so we ignore it.
if (! $channel) {
return;
}
$socketId = $payload->socketId ?? null;
$serverId = $payload->serverId ?? null;
// Remove fields intended for internal use from the payload.
unset($payload->socketId);
unset($payload->serverId);
unset($payload->appId);
// Push the message out to connected websocket clients.
$channel->broadcastToEveryoneExcept($payload, $socketId, $appId, false);
DashboardLogger::log($appId, DashboardLogger::TYPE_REPLICATOR_MESSAGE_RECEIVED, [
'channel' => $channel->getChannelName(),
'redisChannel' => $redisChannel,
'serverId' => $this->getServerId(),
'incomingServerId' => $serverId,
'incomingSocketId' => $socketId,
'payload' => $payload,
]);
}
/**
* Build the Redis connection URL from Laravel database config.
*
* @return string
*/
protected function getConnectionUri()
{
$name = config('websockets.replication.redis.connection', 'default');
$config = config("database.redis.{$name}");
$host = $config['host'];
$port = $config['port'] ?: 6379;
$query = [];
if ($config['password']) {
$query['password'] = $config['password'];
}
if ($config['database']) {
$query['database'] = $config['database'];
}
$query = http_build_query($query);
return "redis://{$host}:{$port}".($query ? "?{$query}" : '');
}
/**
* Get the Subscribe client instance.
*
* @return Client
*/
public function getSubscribeClient()
{
return $this->subscribeClient;
}
/**
* Get the Publish client instance.
*
* @return Client
*/
public function getPublishClient()
{
return $this->publishClient;
}
/**
* Get the unique identifier for the server.
*
* @return string
*/
public function getServerId()
{
return $this->serverId;
}
/**
* Get the Pub/Sub Topic name to subscribe based on the
* app ID and channel name.
*
* @param mixed $appId
* @param string|null $channel
* @return string
*/
protected function getTopicName($appId, string $channel = null): string
{
$prefix = config('database.redis.options.prefix', null);
$hash = "{$prefix}{$appId}";
if ($channel) {
$hash .= ":{$channel}";
}
return $hash;
}
}

View File

@ -1,120 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\PubSub;
use React\EventLoop\LoopInterface;
use React\Promise\PromiseInterface;
use stdClass;
interface ReplicationInterface
{
/**
* Boot the pub/sub provider (open connections, initial subscriptions, etc).
*
* @param LoopInterface $loop
* @param string|null $factoryClass
* @return self
*/
public function boot(LoopInterface $loop, $factoryClass = null): self;
/**
* Publish a payload on a specific channel, for a specific app.
*
* @param string $appId
* @param string $channel
* @param stdClass $payload
* @return bool
*/
public function publish($appId, string $channel, stdClass $payload): bool;
/**
* Subscribe to receive messages for a channel.
*
* @param string $appId
* @param string $channel
* @return bool
*/
public function subscribe($appId, string $channel): bool;
/**
* Unsubscribe from a channel.
*
* @param string $appId
* @param string $channel
* @return bool
*/
public function unsubscribe($appId, string $channel): bool;
/**
* Subscribe to the app's pubsub keyspace.
*
* @param mixed $appId
* @return bool
*/
public function subscribeToApp($appId): bool;
/**
* Unsubscribe from the app's pubsub keyspace.
*
* @param mixed $appId
* @return bool
*/
public function unsubscribeFromApp($appId): bool;
/**
* Add a member to a channel. To be called when they have
* subscribed to the channel.
*
* @param string $appId
* @param string $channel
* @param string $socketId
* @param string $data
* @return void
*/
public function joinChannel($appId, string $channel, string $socketId, string $data);
/**
* Remove a member from the channel. To be called when they have
* unsubscribed from the channel.
*
* @param string $appId
* @param string $channel
* @param string $socketId
* @return void
*/
public function leaveChannel($appId, string $channel, string $socketId);
/**
* Retrieve the full information about the members in a presence channel.
*
* @param string $appId
* @param string $channel
* @return PromiseInterface
*/
public function channelMembers($appId, string $channel): PromiseInterface;
/**
* Get the amount of users subscribed for each presence channel.
*
* @param string $appId
* @param array $channelNames
* @return PromiseInterface
*/
public function channelMemberCounts($appId, array $channelNames): PromiseInterface;
/**
* Get the amount of unique connections.
*
* @param mixed $appId
* @return null|int
*/
public function getLocalConnectionsCount($appId);
/**
* Get the amount of connections aggregated on multiple instances.
*
* @param mixed $appId
* @return null|int|\React\Promise\PromiseInterface
*/
public function getGlobalConnectionsCount($appId);
}

View File

@ -1,8 +1,8 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\Statistics\Rules; namespace BeyondCode\LaravelWebSockets\Rules;
use BeyondCode\LaravelWebSockets\Apps\AppManager; use BeyondCode\LaravelWebSockets\Contracts\AppManager;
use Illuminate\Contracts\Validation\Rule; use Illuminate\Contracts\Validation\Rule;
class AppId implements Rule class AppId implements Rule

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Exceptions; namespace BeyondCode\LaravelWebSockets\Server\Exceptions;
class ConnectionsOverCapacity extends WebSocketException class ConnectionsOverCapacity extends WebSocketException
{ {
@ -12,7 +12,6 @@ class ConnectionsOverCapacity extends WebSocketException
*/ */
public function __construct() public function __construct()
{ {
$this->message = 'Over capacity'; $this->trigger("Over capacity", 4100);
$this->code = 4100;
} }
} }

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Exceptions; namespace BeyondCode\LaravelWebSockets\Server\Exceptions;
class InvalidSignature extends WebSocketException class InvalidSignature extends WebSocketException
{ {
@ -12,7 +12,6 @@ class InvalidSignature extends WebSocketException
*/ */
public function __construct() public function __construct()
{ {
$this->message = 'Invalid Signature'; $this->trigger("Invalid Signature", 4009);
$this->code = 4009;
} }
} }

View File

@ -0,0 +1,17 @@
<?php
namespace BeyondCode\LaravelWebSockets\Server\Exceptions;
class OriginNotAllowed extends WebSocketException
{
/**
* Initalize the exception.
*
* @param string $appKey
* @return void
*/
public function __construct($appKey)
{
$this->trigger("The origin is not allowed for `{$appKey}`.", 4009);
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace BeyondCode\LaravelWebSockets\Server\Exceptions;
class UnknownAppKey extends WebSocketException
{
/**
* Initalize the exception.
*
* @param string $appKey
* @return void
*/
public function __construct($appKey)
{
$this->trigger("Could not find app key `{$appKey}`.", 4001);
}
}

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Exceptions; namespace BeyondCode\LaravelWebSockets\Server\Exceptions;
use Exception; use Exception;
@ -21,4 +21,17 @@ class WebSocketException extends Exception
], ],
]; ];
} }
/**
* 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;
}
} }

View File

@ -3,8 +3,9 @@
namespace BeyondCode\LaravelWebSockets\Server; namespace BeyondCode\LaravelWebSockets\Server;
use Ratchet\Http\HttpServerInterface; use Ratchet\Http\HttpServerInterface;
use Ratchet\Http\HttpServer as BaseHttpServer;
class HttpServer extends \Ratchet\Http\HttpServer class HttpServer extends BaseHttpServer
{ {
/** /**
* Create a new server instance. * Create a new server instance.

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\Server\Logger; namespace BeyondCode\LaravelWebSockets\Server\Loggers;
use Ratchet\ConnectionInterface; use Ratchet\ConnectionInterface;
@ -42,7 +42,7 @@ class ConnectionLogger extends Logger implements ConnectionInterface
/** /**
* Send data through the connection. * Send data through the connection.
* *
* @param mixed $data * @param string $data
* @return void * @return void
*/ */
public function send($data) public function send($data)

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\Server\Logger; namespace BeyondCode\LaravelWebSockets\Server\Loggers;
use Exception; use Exception;
use Ratchet\ConnectionInterface; use Ratchet\ConnectionInterface;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\Server\Logger; namespace BeyondCode\LaravelWebSockets\Server\Loggers;
use Symfony\Component\Console\Formatter\OutputFormatterStyle; use Symfony\Component\Console\Formatter\OutputFormatterStyle;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@ -35,7 +35,9 @@ class Logger
*/ */
public static function isEnabled(): bool public static function isEnabled(): bool
{ {
return app(WebsocketsLogger::class)->enabled; $logger = app(WebSocketsLogger::class);
return $logger->enabled;
} }
/** /**

View File

@ -1,14 +1,14 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\Server\Logger; namespace BeyondCode\LaravelWebSockets\Server\Loggers;
use BeyondCode\LaravelWebSockets\QueryParameters; use BeyondCode\LaravelWebSockets\Server\QueryParameters;
use Exception; use Exception;
use Ratchet\ConnectionInterface; use Ratchet\ConnectionInterface;
use Ratchet\RFC6455\Messaging\MessageInterface; use Ratchet\RFC6455\Messaging\MessageInterface;
use Ratchet\WebSocket\MessageComponentInterface; use Ratchet\WebSocket\MessageComponentInterface;
class WebsocketsLogger extends Logger implements MessageComponentInterface class WebSocketsLogger extends Logger implements MessageComponentInterface
{ {
/** /**
* The HTTP app instance to watch. * The HTTP app instance to watch.

View File

@ -1,51 +1,15 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Messages; namespace BeyondCode\LaravelWebSockets\Server\Messages;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager; use BeyondCode\LaravelWebSockets\Contracts\ChannelManager;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Ratchet\ConnectionInterface; use Ratchet\ConnectionInterface;
use stdClass; use stdClass;
use BeyondCode\LaravelWebSockets\Contracts\PusherMessage;
class PusherChannelProtocolMessage implements PusherMessage class PusherChannelProtocolMessage extends PusherClientMessage
{ {
/**
* The payload to send.
*
* @var \stdClass
*/
protected $payload;
/**
* The socket connection.
*
* @var \Ratchet\ConnectionInterface
*/
protected $connection;
/**
* The channel manager.
*
* @var ChannelManager
*/
protected $channelManager;
/**
* Create a new instance.
*
* @param \stdClass $payload
* @param \Ratchet\ConnectionInterface $connection
* @param ChannelManager $channelManager
*/
public function __construct(stdClass $payload, ConnectionInterface $connection, ChannelManager $channelManager)
{
$this->payload = $payload;
$this->connection = $connection;
$this->channelManager = $channelManager;
}
/** /**
* Respond with the payload. * Respond with the payload.
* *
@ -84,9 +48,7 @@ class PusherChannelProtocolMessage implements PusherMessage
*/ */
protected function subscribe(ConnectionInterface $connection, stdClass $payload) protected function subscribe(ConnectionInterface $connection, stdClass $payload)
{ {
$channel = $this->channelManager->findOrCreate($connection->app->id, $payload->channel); $this->channelManager->subscribeToChannel($connection, $payload->channel, $payload);
$channel->subscribe($connection, $payload);
} }
/** /**
@ -98,8 +60,6 @@ class PusherChannelProtocolMessage implements PusherMessage
*/ */
public function unsubscribe(ConnectionInterface $connection, stdClass $payload) public function unsubscribe(ConnectionInterface $connection, stdClass $payload)
{ {
$channel = $this->channelManager->findOrCreate($connection->app->id, $payload->channel); $this->channelManager->unsubscribeFromChannel($connection, $payload->channel, $payload);
$channel->unsubscribe($connection);
} }
} }

View File

@ -1,12 +1,13 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Messages; namespace BeyondCode\LaravelWebSockets\Server\Messages;
use BeyondCode\LaravelWebSockets\Dashboard\DashboardLogger; use BeyondCode\LaravelWebSockets\DashboardLogger;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager; use BeyondCode\LaravelWebSockets\Contracts\ChannelManager;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Ratchet\ConnectionInterface; use Ratchet\ConnectionInterface;
use stdClass; use stdClass;
use BeyondCode\LaravelWebSockets\Contracts\PusherMessage;
class PusherClientMessage implements PusherMessage class PusherClientMessage implements PusherMessage
{ {
@ -60,6 +61,14 @@ class PusherClientMessage implements PusherMessage
return; 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, [ DashboardLogger::log($this->connection->app->id, DashboardLogger::TYPE_WS_MESSAGE, [
'socketId' => $this->connection->socketId, 'socketId' => $this->connection->socketId,
'channel' => $this->payload->channel, 'channel' => $this->payload->channel,
@ -67,8 +76,5 @@ class PusherClientMessage implements PusherMessage
'data' => $this->payload, 'data' => $this->payload,
]); ]);
$channel = $this->channelManager->find($this->connection->app->id, $this->payload->channel);
optional($channel)->broadcastToOthers($this->connection, $this->payload);
} }
} }

View File

@ -1,11 +1,12 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Messages; namespace BeyondCode\LaravelWebSockets\Server\Messages;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Ratchet\ConnectionInterface; use Ratchet\ConnectionInterface;
use Ratchet\RFC6455\Messaging\MessageInterface; use Ratchet\RFC6455\Messaging\MessageInterface;
use BeyondCode\LaravelWebSockets\Contracts\ChannelManager;
use BeyondCode\LaravelWebSockets\Contracts\PusherMessage;
class PusherMessageFactory class PusherMessageFactory
{ {
@ -14,7 +15,7 @@ class PusherMessageFactory
* *
* @param \Ratchet\RFC6455\Messaging\MessageInterface $message * @param \Ratchet\RFC6455\Messaging\MessageInterface $message
* @param \Ratchet\ConnectionInterface $connection * @param \Ratchet\ConnectionInterface $connection
* @param \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager $channelManager * @param \BeyondCode\LaravelWebSockets\Contracts\ChannelManager $channelManager
* @return PusherMessage * @return PusherMessage
*/ */
public static function createForMessage( public static function createForMessage(

View File

@ -1,6 +1,6 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets; namespace BeyondCode\LaravelWebSockets\Server;
use Psr\Http\Message\RequestInterface; use Psr\Http\Message\RequestInterface;

View File

@ -2,13 +2,7 @@
namespace BeyondCode\LaravelWebSockets\Server; namespace BeyondCode\LaravelWebSockets\Server;
use BeyondCode\LaravelWebSockets\Exceptions\InvalidWebSocketController; use BeyondCode\LaravelWebSockets\Server\Loggers\WebSocketsLogger;
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 Illuminate\Support\Collection;
use Ratchet\WebSocket\MessageComponentInterface; use Ratchet\WebSocket\MessageComponentInterface;
use Ratchet\WebSocket\WsServer; use Ratchet\WebSocket\WsServer;
@ -21,16 +15,9 @@ class Router
* The implemented routes. * The implemented routes.
* *
* @var \Symfony\Component\Routing\RouteCollection * @var \Symfony\Component\Routing\RouteCollection
*/ */
protected $routes; protected $routes;
/**
* The custom routes defined by the user.
*
* @var \Symfony\Component\Routing\RouteCollection
*/
protected $customRoutes;
/** /**
* Initialize the class. * Initialize the class.
* *
@ -39,7 +26,6 @@ class Router
public function __construct() public function __construct()
{ {
$this->routes = new RouteCollection; $this->routes = new RouteCollection;
$this->customRoutes = new Collection();
} }
/** /**
@ -53,22 +39,17 @@ class Router
} }
/** /**
* Register the routes. * Register the default routes.
* *
* @return void * @return void
*/ */
public function routes() public function routes()
{ {
$this->get('/app/{appKey}', config('websockets.handlers.websocket', WebSocketHandler::class)); $this->get('/app/{appKey}', config('websockets.handlers.websocket'));
$this->post('/apps/{appId}/events', config('websockets.handlers.trigger_event'));
$this->post('/apps/{appId}/events', config('websockets.handlers.trigger_event', TriggerEventController::class)); $this->get('/apps/{appId}/channels', config('websockets.handlers.fetch_channels'));
$this->get('/apps/{appId}/channels', config('websockets.handlers.fetch_channels', FetchChannelsController::class)); $this->get('/apps/{appId}/channels/{channelName}', config('websockets.handlers.fetch_channel'));
$this->get('/apps/{appId}/channels/{channelName}', config('websockets.handlers.fetch_channel', FetchChannelController::class)); $this->get('/apps/{appId}/channels/{channelName}/users', config('websockets.handlers.fetch_users'));
$this->get('/apps/{appId}/channels/{channelName}/users', config('websockets.handlers.fetch_users', FetchUsersController::class));
$this->customRoutes->each(function ($action, $uri) {
$this->get($uri, $action);
});
} }
/** /**
@ -131,23 +112,6 @@ class Router
$this->addRoute('DELETE', $uri, $action); $this->addRoute('DELETE', $uri, $action);
} }
/**
* Add a WebSocket GET route that should
* comply with the MessageComponentInterface interface.
*
* @param string $uri
* @param string $action
* @return void
*/
public function webSocket(string $uri, $action)
{
if (! is_subclass_of($action, MessageComponentInterface::class)) {
throw InvalidWebSocketController::withController($action);
}
$this->customRoutes->put($uri, $action);
}
/** /**
* Add a new route to the list. * Add a new route to the list.
* *
@ -171,12 +135,6 @@ class Router
*/ */
protected function getRoute(string $method, string $uri, $action): 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 instantiate it.
*/
$action = is_subclass_of($action, MessageComponentInterface::class) $action = is_subclass_of($action, MessageComponentInterface::class)
? $this->createWebSocketsServer($action) ? $this->createWebSocketsServer($action)
: app($action); : app($action);

View File

@ -1,50 +1,34 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\WebSockets; namespace BeyondCode\LaravelWebSockets\Server;
use BeyondCode\LaravelWebSockets\DashboardLogger;
use BeyondCode\LaravelWebSockets\Apps\App; use BeyondCode\LaravelWebSockets\Apps\App;
use BeyondCode\LaravelWebSockets\Dashboard\DashboardLogger;
use BeyondCode\LaravelWebSockets\Facades\StatisticsLogger;
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface;
use BeyondCode\LaravelWebSockets\QueryParameters;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\ConnectionsOverCapacity;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\OriginNotAllowed;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\UnknownAppKey;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\WebSocketException;
use BeyondCode\LaravelWebSockets\WebSockets\Messages\PusherMessageFactory;
use Exception; use Exception;
use Ratchet\WebSocket\MessageComponentInterface;
use Ratchet\ConnectionInterface; use Ratchet\ConnectionInterface;
use Ratchet\RFC6455\Messaging\MessageInterface; use Ratchet\RFC6455\Messaging\MessageInterface;
use Ratchet\WebSocket\MessageComponentInterface; use BeyondCode\LaravelWebSockets\Contracts\ChannelManager;
use React\Promise\PromiseInterface; use BeyondCode\LaravelWebSockets\Facades\StatisticsCollector;
class WebSocketHandler implements MessageComponentInterface class WebSocketHandler implements MessageComponentInterface
{ {
/** /**
* The channel manager. * The channel manager.
* *
* @var \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager * @var ChannelManager
*/ */
protected $channelManager; protected $channelManager;
/**
* The replicator client.
*
* @var ReplicationInterface
*/
protected $replicator;
/** /**
* Initialize a new handler. * Initialize a new handler.
* *
* @param \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager $channelManager * @param \BeyondCode\LaravelWebSockets\Contracts\ChannelManager $channelManager
* @return void * @return void
*/ */
public function __construct(ChannelManager $channelManager) public function __construct(ChannelManager $channelManager)
{ {
$this->channelManager = $channelManager; $this->channelManager = $channelManager;
$this->replicator = app(ReplicationInterface::class);
} }
/** /**
@ -60,6 +44,20 @@ class WebSocketHandler implements MessageComponentInterface
->limitConcurrentConnections($connection) ->limitConcurrentConnections($connection)
->generateSocketId($connection) ->generateSocketId($connection)
->establishConnection($connection); ->establishConnection($connection);
if (isset($connection->app)) {
/** @var \GuzzleHttp\Psr7\Request $request */
$request = $connection->httpRequest;
StatisticsCollector::connection($connection->app->id);
$this->channelManager->subscribeToApp($connection->app->id);
DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_CONNECTED, [
'origin' => "{$request->getUri()->getScheme()}://{$request->getUri()->getHost()}",
'socketId' => $connection->socketId,
]);
}
} }
/** /**
@ -71,11 +69,11 @@ class WebSocketHandler implements MessageComponentInterface
*/ */
public function onMessage(ConnectionInterface $connection, MessageInterface $message) public function onMessage(ConnectionInterface $connection, MessageInterface $message)
{ {
$message = PusherMessageFactory::createForMessage($message, $connection, $this->channelManager); Messages\PusherMessageFactory::createForMessage(
$message, $connection, $this->channelManager
)->respond();
$message->respond(); StatisticsCollector::webSocketMessage($connection->app->id);
StatisticsLogger::webSocketMessage($connection->app->id);
} }
/** /**
@ -86,15 +84,17 @@ class WebSocketHandler implements MessageComponentInterface
*/ */
public function onClose(ConnectionInterface $connection) public function onClose(ConnectionInterface $connection)
{ {
$this->channelManager->removeFromAllChannels($connection); $this->channelManager->unsubscribeFromAllChannels($connection);
DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_DISCONNECTED, [ if (isset($connection->app)) {
'socketId' => $connection->socketId, StatisticsCollector::disconnection($connection->app->id);
]);
StatisticsLogger::disconnection($connection->app->id); $this->channelManager->unsubscribeFromApp($connection->app->id);
$this->replicator->unsubscribeFromApp($connection->app->id); DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_DISCONNECTED, [
'socketId' => $connection->socketId,
]);
}
} }
/** /**
@ -106,13 +106,11 @@ class WebSocketHandler implements MessageComponentInterface
*/ */
public function onError(ConnectionInterface $connection, Exception $exception) public function onError(ConnectionInterface $connection, Exception $exception)
{ {
if ($exception instanceof WebSocketException) { if ($exception instanceof Exceptions\WebSocketException) {
$connection->send(json_encode( $connection->send(json_encode(
$exception->getPayload() $exception->getPayload()
)); ));
} }
$this->replicator->unsubscribeFromApp($connection->app->id);
} }
/** /**
@ -123,10 +121,12 @@ class WebSocketHandler implements MessageComponentInterface
*/ */
protected function verifyAppKey(ConnectionInterface $connection) protected function verifyAppKey(ConnectionInterface $connection)
{ {
$appKey = QueryParameters::create($connection->httpRequest)->get('appKey'); $query = QueryParameters::create($connection->httpRequest);
$appKey = $query->get('appKey');
if (! $app = App::findByKey($appKey)) { if (! $app = App::findByKey($appKey)) {
throw new UnknownAppKey($appKey); throw new Exceptions\UnknownAppKey($appKey);
} }
$connection->app = $app; $connection->app = $app;
@ -151,7 +151,7 @@ class WebSocketHandler implements MessageComponentInterface
$origin = parse_url($header, PHP_URL_HOST) ?: $header; $origin = parse_url($header, PHP_URL_HOST) ?: $header;
if (! $header || ! in_array($origin, $connection->app->allowedOrigins)) { if (! $header || ! in_array($origin, $connection->app->allowedOrigins)) {
throw new OriginNotAllowed($connection->app->key); throw new Exceptions\OriginNotAllowed($connection->app->key);
} }
return $this; return $this;
@ -166,17 +166,17 @@ class WebSocketHandler implements MessageComponentInterface
protected function limitConcurrentConnections(ConnectionInterface $connection) protected function limitConcurrentConnections(ConnectionInterface $connection)
{ {
if (! is_null($capacity = $connection->app->capacity)) { if (! is_null($capacity = $connection->app->capacity)) {
$connectionsCount = $this->channelManager->getGlobalConnectionsCount($connection->app->id); $this->channelManager
->getGlobalConnectionsCount($connection->app->id)
->then(function ($connectionsCount) use ($capacity, $connection) {
if ($connectionsCount >= $capacity) {
$exception = new Exceptions\ConnectionsOverCapacity;
if ($connectionsCount instanceof PromiseInterface) { $payload = json_encode($exception->getPayload());
$connectionsCount->then(function ($connectionsCount) use ($capacity, $connection) {
$connectionsCount = $connectionsCount ?: 0;
$this->sendExceptionIfOverCapacity($connectionsCount, $capacity, $connection); tap($connection)->send($payload)->close();
}
}); });
} else {
$this->throwExceptionIfOverCapacity($connectionsCount, $capacity);
}
} }
return $this; return $this;
@ -213,51 +213,6 @@ class WebSocketHandler implements MessageComponentInterface
]), ]),
])); ]));
/** @var \GuzzleHttp\Psr7\Request $request */
$request = $connection->httpRequest;
DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_CONNECTED, [
'origin' => "{$request->getUri()->getScheme()}://{$request->getUri()->getHost()}",
'socketId' => $connection->socketId,
]);
StatisticsLogger::connection($connection->app->id);
$this->replicator->subscribeToApp($connection->app->id);
return $this; return $this;
} }
/**
* Throw a ConnectionsOverCapacity exception.
*
* @param int $connectionsCount
* @param int $capacity
* @return void
* @throws ConnectionsOverCapacity
*/
protected function throwExceptionIfOverCapacity(int $connectionsCount, int $capacity)
{
if ($connectionsCount >= $capacity) {
throw new ConnectionsOverCapacity;
}
}
/**
* Send the ConnectionsOverCapacity exception through
* the connection and close the channel.
*
* @param int $connectionsCount
* @param int $capacity
* @param ConnectionInterface $connection
* @return void
*/
protected function sendExceptionIfOverCapacity(int $connectionsCount, int $capacity, ConnectionInterface $connection)
{
if ($connectionsCount >= $capacity) {
$payload = json_encode((new ConnectionsOverCapacity)->getPayload());
tap($connection)->send($payload)->close();
}
}
} }

View File

@ -1,8 +1,7 @@
<?php <?php
namespace BeyondCode\LaravelWebSockets\Server; namespace BeyondCode\LaravelWebSockets;
use BeyondCode\LaravelWebSockets\Server\Logger\HttpLogger;
use Ratchet\Http\Router; use Ratchet\Http\Router;
use Ratchet\Server\IoServer; use Ratchet\Server\IoServer;
use React\EventLoop\Factory as LoopFactory; use React\EventLoop\Factory as LoopFactory;
@ -13,8 +12,10 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Routing\Matcher\UrlMatcher; use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext; use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RouteCollection;
use BeyondCode\LaravelWebSockets\Server\HttpServer;
use BeyondCode\LaravelWebSockets\Server\Loggers\HttpLogger;
class WebSocketServerFactory class ServerFactory
{ {
/** /**
* The host the server will run on. * The host the server will run on.
@ -69,10 +70,10 @@ class WebSocketServerFactory
/** /**
* Add the routes. * Add the routes.
* *
* @param \Symfony\Component\Routing\RouteCollection $routes * @param \Symfony\Component\Routing\RouteCollection $routes
* @return $this * @return $this
*/ */
public function useRoutes(RouteCollection $routes) public function withRoutes(RouteCollection $routes)
{ {
$this->routes = $routes; $this->routes = $routes;

View File

@ -0,0 +1,171 @@
<?php
namespace BeyondCode\LaravelWebSockets\Statistics\Collectors;
use React\Promise\FulfilledPromise;
use React\Promise\PromiseInterface;
use BeyondCode\LaravelWebSockets\Facades\StatisticsStore;
use BeyondCode\LaravelWebSockets\Statistics\Statistic;
use BeyondCode\LaravelWebSockets\Contracts\StatisticsCollector;
use BeyondCode\LaravelWebSockets\Contracts\ChannelManager;
class MemoryCollector implements StatisticsCollector
{
/**
* The list of stored statistics.
*
* @var array
*/
protected $statistics = [];
/**
* The Channel manager.
*
* @var \BeyondCode\LaravelWebSockets\Contracts\ChannelManager
*/
protected $channelManager;
/**
* Initialize the logger.
*
* @return void
*/
public function __construct()
{
$this->channelManager = app(ChannelManager::class);
}
/**
* Handle the incoming websocket message.
*
* @param string|int $appId
* @return void
*/
public function webSocketMessage($appId)
{
$this->findOrMake($appId)
->webSocketMessage();
}
/**
* Handle the incoming API message.
*
* @param string|int $appId
* @return void
*/
public function apiMessage($appId)
{
$this->findOrMake($appId)
->apiMessage();
}
/**
* Handle the new conection.
*
* @param string|int $appId
* @return void
*/
public function connection($appId)
{
$this->findOrMake($appId)
->connection();
}
/**
* Handle disconnections.
*
* @param string|int $appId
* @return void
*/
public function disconnection($appId)
{
$this->findOrMake($appId)
->disconnection();
}
/**
* Save all the stored statistics.
*
* @return void
*/
public function save()
{
$this->getStatistics()->then(function ($statistics) {
foreach ($statistics as $appId => $statistic) {
if (! $statistic->isEnabled()) {
continue;
}
$this->createRecord($statistic, $appId);
$this->channelManager
->getGlobalConnectionsCount($appId)
->then(function ($connections) use ($statistic) {
$statistic->reset(
is_null($connections) ? 0 : $connections
);
});
}
});
}
/**
* Flush the stored statistics.
*
* @return void
*/
public function flush()
{
$this->statistics = [];
}
/**
* Get the saved statistics.
*
* @return PromiseInterface[array]
*/
public function getStatistics(): PromiseInterface
{
return new FulfilledPromise($this->statistics);
}
/**
* Get the saved statistics for an app.
*
* @param string|int $appId
* @return PromiseInterface[\BeyondCode\LaravelWebSockets\Statistics\Statistic|null]
*/
public function getAppStatistics($appId): PromiseInterface
{
return new FulfilledPromise(
$this->statistics[$appId] ?? null
);
}
/**
* Find or create a defined statistic for an app.
*
* @param string|int $appId
* @return \BeyondCode\LaravelWebSockets\Statistics\Statistic
*/
protected function findOrMake($appId): Statistic
{
if (! isset($this->statistics[$appId])) {
$this->statistics[$appId] = new Statistic($appId);
}
return $this->statistics[$appId];
}
/**
* Create a new record using the Statistic Store.
*
* @param \BeyondCode\LaravelWebSockets\Statistics\Statistic $statistic
* @param mixed $appId
* @return void
*/
public function createRecord(Statistic $statistic, $appId)
{
StatisticsStore::store($statistic->toArray());
}
}

View File

@ -0,0 +1,407 @@
<?php
namespace BeyondCode\LaravelWebSockets\Statistics\Collectors;
use React\Promise\FulfilledPromise;
use React\Promise\PromiseInterface;
use BeyondCode\LaravelWebSockets\Facades\StatisticsStore;
use BeyondCode\LaravelWebSockets\Statistics\Statistic;
use BeyondCode\LaravelWebSockets\Contracts\StatisticsCollector;
use BeyondCode\LaravelWebSockets\Contracts\ChannelManager;
use Illuminate\Cache\RedisLock;
use Illuminate\Support\Facades\Redis;
class RedisCollector extends MemoryCollector
{
/**
* The Redis manager instance.
*
* @var \Illuminate\Redis\RedisManager
*/
protected $redis;
/**
* The set name for the Redis storage.
*
* @var string
*/
protected static $redisSetName = 'laravel-websockets:apps';
/**
* The lock name to use on Redis to avoid multiple
* collector-to-store actions that may result
* in multiple data points set to the store.
*
* @var string
*/
protected static $redisLockName = 'laravel-websockets:lock';
/**
* Initialize the logger.
*
* @return void
*/
public function __construct()
{
parent::__construct();
$this->redis = Redis::connection(
config('websockets.replication.modes.redis.connection', 'default')
);
}
/**
* Handle the incoming websocket message.
*
* @param string|int $appId
* @return void
*/
public function webSocketMessage($appId)
{
$this->ensureAppIsInSet($appId)
->hincrby(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'websocket_messages_count', 1
);
}
/**
* Handle the incoming API message.
*
* @param string|int $appId
* @return void
*/
public function apiMessage($appId)
{
$this->ensureAppIsInSet($appId)
->hincrby(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'api_messages_count', 1
);
}
/**
* Handle the new conection.
*
* @param string|int $appId
* @return void
*/
public function connection($appId)
{
// Increment the current connections count by 1.
$this->ensureAppIsInSet($appId)
->hincrby(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'current_connections_count', 1
)
->then(function ($currentConnectionsCount) use ($appId) {
// Get the peak connections count from Redis.
$this->channelManager
->getPublishClient()
->hget(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'peak_connections_count'
)
->then(function ($currentPeakConnectionCount) use ($currentConnectionsCount, $appId) {
// Extract the greatest number between the current peak connection count
// and the current connection number.
$peakConnectionsCount = is_null($currentPeakConnectionCount)
? $currentConnectionsCount
: max($currentPeakConnectionCount, $currentConnectionsCount);
// Then set it to the database.
$this->channelManager
->getPublishClient()
->hset(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'peak_connections_count', $peakConnectionsCount
);
});
});
}
/**
* Handle disconnections.
*
* @param string|int $appId
* @return void
*/
public function disconnection($appId)
{
// Decrement the current connections count by 1.
$this->ensureAppIsInSet($appId)
->hincrby(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'current_connections_count', -1
)
->then(function ($currentConnectionsCount) use ($appId) {
// Get the peak connections count from Redis.
$this->channelManager
->getPublishClient()
->hget(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'peak_connections_count'
)
->then(function ($currentPeakConnectionCount) use ($currentConnectionsCount, $appId) {
// Extract the greatest number between the current peak connection count
// and the current connection number.
$peakConnectionsCount = is_null($currentPeakConnectionCount)
? $currentConnectionsCount
: max($currentPeakConnectionCount, $currentConnectionsCount);
// Then set it to the database.
$this->channelManager
->getPublishClient()
->hset(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'peak_connections_count', $peakConnectionsCount
);
});
});
}
/**
* Save all the stored statistics.
*
* @return void
*/
public function save()
{
$this->lock()->get(function () {
$this->channelManager
->getPublishClient()
->smembers(static::$redisSetName)
->then(function ($members) {
foreach ($members as $appId) {
$this->channelManager
->getPublishClient()
->hgetall($this->channelManager->getRedisKey($appId, null, ['stats']))
->then(function ($list) use ($appId) {
if (! $list) {
return;
}
$statistic = $this->listToStatisticInstance(
$appId, $list
);
$this->createRecord($statistic, $appId);
$this->channelManager
->getGlobalConnectionsCount($appId)
->then(function ($currentConnectionsCount) use ($appId) {
$currentConnectionsCount === 0 || is_null($currentConnectionsCount)
? $this->resetAppTraces($appId)
: $this->resetStatistics($appId, $currentConnectionsCount);
});
});
}
});
});
}
/**
* Flush the stored statistics.
*
* @return void
*/
public function flush()
{
$this->getStatistics()->then(function ($statistics) {
foreach ($statistics as $appId => $statistic) {
$this->resetAppTraces($appId);
}
});
}
/**
* Get the saved statistics.
*
* @return PromiseInterface[array]
*/
public function getStatistics(): PromiseInterface
{
return $this->channelManager
->getPublishClient()
->smembers(static::$redisSetName)
->then(function ($members) use (&$statistics) {
$appsWithStatistics = [];
foreach ($members as $appId) {
$this->channelManager
->getPublishClient()
->hgetall($this->channelManager->getRedisKey($appId, null, ['stats']))
->then(function ($list) use ($appId, &$appsWithStatistics) {
$appsWithStatistics[$appId] = $this->listToStatisticInstance(
$appId, $list
);
});
}
return $appsWithStatistics;
});
}
/**
* Get the saved statistics for an app.
*
* @param string|int $appId
* @return PromiseInterface[\BeyondCode\LaravelWebSockets\Statistics\Statistic|null]
*/
public function getAppStatistics($appId): PromiseInterface
{
return $this->channelManager
->getPublishClient()
->hgetall($this->channelManager->getRedisKey($appId, null, ['stats']))
->then(function ($list) use ($appId, &$appStatistics) {
return $this->listToStatisticInstance(
$appId, $list
);
});
}
/**
* Reset the statistics to a specific connection count.
*
* @param string|int $appId
* @param int $currentConnectionCount
* @return void
*/
public function resetStatistics($appId, int $currentConnectionCount)
{
$this->channelManager
->getPublishClient()
->hset(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'current_connections_count', $currentConnectionCount
);
$this->channelManager
->getPublishClient()
->hset(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'peak_connections_count', $currentConnectionCount
);
$this->channelManager
->getPublishClient()
->hset(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'websocket_messages_count', 0
);
$this->channelManager
->getPublishClient()
->hset(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'api_messages_count', 0
);
}
/**
* Remove all app traces from the database if no connections have been set
* in the meanwhile since last save.
*
* @param string|int $appId
* @return void
*/
public function resetAppTraces($appId)
{
$this->channelManager
->getPublishClient()
->hdel(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'current_connections_count'
);
$this->channelManager
->getPublishClient()
->hdel(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'peak_connections_count'
);
$this->channelManager
->getPublishClient()
->hdel(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'websocket_messages_count'
);
$this->channelManager
->getPublishClient()
->hdel(
$this->channelManager->getRedisKey($appId, null, ['stats']),
'api_messages_count'
);
$this->channelManager
->getPublishClient()
->srem(static::$redisSetName, $appId);
}
/**
* Ensure the app id is stored in the Redis database.
*
* @param string|int $appId
* @return \Clue\React\Redis\Client
*/
protected function ensureAppIsInSet($appId)
{
$this->channelManager
->getPublishClient()
->sadd(static::$redisSetName, $appId);
return $this->channelManager->getPublishClient();
}
/**
* Get a new RedisLock instance to avoid race conditions.
*
* @return \Illuminate\Cache\CacheLock
*/
protected function lock()
{
return new RedisLock($this->redis, static::$redisLockName, 0);
}
/**
* Transform the Redis' list of key after value
* to key-value pairs.
*
* @param array $list
* @return array
*/
protected function listToKeyValue(array $list)
{
// Redis lists come into a format where the keys are on even indexes
// and the values are on odd indexes. This way, we know which
// ones are keys and which ones are values and their get combined
// later to form the key => value array.
[$keys, $values] = collect($list)->partition(function ($value, $key) {
return $key % 2 === 0;
});
return array_combine($keys->all(), $values->all());
}
/**
* Transform a list coming from a Redis list
* to a Statistic instance.
*
* @param string|int $appId
* @param array $list
* @return \BeyondCode\LaravelWebSockets\Statistics\Statistic
*/
protected function listToStatisticInstance($appId, array $list)
{
$list = $this->listToKeyValue($list);
return (new Statistic($appId))
->setCurrentConnectionsCount($list['current_connections_count'] ?? 0)
->setPeakConnectionsCount($list['peak_connections_count'] ?? 0)
->setWebSocketMessagesCount($list['websocket_messages_count'] ?? 0)
->setApiMessagesCount($list['api_messages_count'] ?? 0);
}
}

View File

@ -1,153 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Statistics\Drivers;
use Carbon\Carbon;
use Illuminate\Http\Request;
class DatabaseDriver implements StatisticsDriver
{
/**
* The model that controls the database table.
*
* @var \BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry|null
*/
protected $record;
/**
* Initialize the driver.
*
* @param \BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry|null $record
* @return void
*/
public function __construct($record = null)
{
$this->record = $record;
}
/**
* Get the app ID for the stats.
*
* @return mixed
*/
public function getAppId()
{
return $this->record->app_id;
}
/**
* Get the time value. Should be Y-m-d H:i:s.
*
* @return string
*/
public function getTime(): string
{
return Carbon::parse($this->record->created_at)->toDateTimeString();
}
/**
* Get the peak connection count for the time.
*
* @return int
*/
public function getPeakConnectionCount(): int
{
return $this->record->peak_connection_count ?? 0;
}
/**
* Get the websocket messages count for the time.
*
* @return int
*/
public function getWebsocketMessageCount(): int
{
return $this->record->websocket_message_count ?? 0;
}
/**
* Get the API message count for the time.
*
* @return int
*/
public function getApiMessageCount(): int
{
return $this->record->api_message_count ?? 0;
}
/**
* Create a new statistic in the store.
*
* @param array $data
* @return \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver
*/
public static function create(array $data): StatisticsDriver
{
$class = config('websockets.statistics.database.model');
return new static($class::create($data));
}
/**
* Get the records to show to the dashboard.
*
* @param mixed $appId
* @param \Illuminate\Http\Request|null $request
* @return array
*/
public static function get($appId, ?Request $request): array
{
$class = config('websockets.statistics.database.model');
$statistics = $class::whereAppId($appId)
->latest()
->limit(120)
->get()
->map(function ($statistic) {
return [
'timestamp' => (string) $statistic->created_at,
'peak_connection_count' => $statistic->peak_connection_count,
'websocket_message_count' => $statistic->websocket_message_count,
'api_message_count' => $statistic->api_message_count,
];
})->reverse();
return [
'peak_connections' => [
'x' => $statistics->pluck('timestamp'),
'y' => $statistics->pluck('peak_connection_count'),
],
'websocket_message_count' => [
'x' => $statistics->pluck('timestamp'),
'y' => $statistics->pluck('websocket_message_count'),
],
'api_message_count' => [
'x' => $statistics->pluck('timestamp'),
'y' => $statistics->pluck('api_message_count'),
],
];
}
/**
* Delete statistics from the store,
* optionally by app id, returning
* the number of deleted records.
*
* @param mixed $appId
* @return int
*/
public static function delete($appId = null): int
{
$cutOffDate = Carbon::now()->subDay(
config('websockets.statistics.delete_statistics_older_than_days')
)->format('Y-m-d H:i:s');
$class = config('websockets.statistics.database.model');
return $class::where('created_at', '<', $cutOffDate)
->when($appId, function ($query) use ($appId) {
return $query->whereAppId($appId);
})
->delete();
}
}

View File

@ -1,78 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Statistics\Drivers;
use Illuminate\Http\Request;
interface StatisticsDriver
{
/**
* Initialize the driver with a stored record.
*
* @param mixed $record
* @return void
*/
public function __construct($record = null);
/**
* Get the app ID for the stats.
*
* @return mixed
*/
public function getAppId();
/**
* Get the time value. Should be Y-m-d H:i:s.
*
* @return string
*/
public function getTime(): string;
/**
* Get the peak connection count for the time.
*
* @return int
*/
public function getPeakConnectionCount(): int;
/**
* Get the websocket messages count for the time.
*
* @return int
*/
public function getWebsocketMessageCount(): int;
/**
* Get the API message count for the time.
*
* @return int
*/
public function getApiMessageCount(): int;
/**
* Create a new statistic in the store.
*
* @param array $data
* @return \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver
*/
public static function create(array $data): StatisticsDriver;
/**
* Get the records to show to the dashboard.
*
* @param mixed $appId
* @param \Illuminate\Http\Request|null $request
* @return void
*/
public static function get($appId, ?Request $request);
/**
* Delete statistics from the store,
* optionally by app id, returning
* the number of deleted records.
*
* @param mixed $appId
* @return int
*/
public static function delete($appId = null): int;
}

View File

@ -1,150 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Statistics\Logger;
use BeyondCode\LaravelWebSockets\Apps\App;
use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver;
use BeyondCode\LaravelWebSockets\Statistics\Statistic;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
class MemoryStatisticsLogger implements StatisticsLogger
{
/**
* The list of stored statistics.
*
* @var array
*/
protected $statistics = [];
/**
* The Channel manager.
*
* @var \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager
*/
protected $channelManager;
/**
* The statistics driver instance.
*
* @var \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver
*/
protected $driver;
/**
* Initialize the logger.
*
* @param \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager $channelManager
* @param \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver $driver
* @return void
*/
public function __construct(ChannelManager $channelManager, StatisticsDriver $driver)
{
$this->channelManager = $channelManager;
$this->driver = $driver;
}
/**
* Handle the incoming websocket message.
*
* @param mixed $appId
* @return void
*/
public function webSocketMessage($appId)
{
$this->findOrMakeStatisticForAppId($appId)
->webSocketMessage();
}
/**
* Handle the incoming API message.
*
* @param mixed $appId
* @return void
*/
public function apiMessage($appId)
{
$this->findOrMakeStatisticForAppId($appId)
->apiMessage();
}
/**
* Handle the new conection.
*
* @param mixed $appId
* @return void
*/
public function connection($appId)
{
$this->findOrMakeStatisticForAppId($appId)
->connection();
}
/**
* Handle disconnections.
*
* @param mixed $appId
* @return void
*/
public function disconnection($appId)
{
$this->findOrMakeStatisticForAppId($appId)
->disconnection();
}
/**
* Save all the stored statistics.
*
* @return void
*/
public function save()
{
foreach ($this->statistics as $appId => $statistic) {
if (! $statistic->isEnabled()) {
continue;
}
$this->createRecord($statistic, $appId);
$currentConnectionCount = $this->channelManager->getGlobalConnectionsCount($appId);
$statistic->reset($currentConnectionCount);
}
}
/**
* Find or create a defined statistic for an app.
*
* @param mixed $appId
* @return Statistic
*/
protected function findOrMakeStatisticForAppId($appId): Statistic
{
if (! isset($this->statistics[$appId])) {
$this->statistics[$appId] = new Statistic($appId);
}
return $this->statistics[$appId];
}
/**
* Get the saved statistics.
*
* @return array
*/
public function getStatistics(): array
{
return $this->statistics;
}
/**
* Create a new record using the Statistic Driver.
*
* @param Statistic $statistic
* @param mixed $appId
* @return void
*/
public function createRecord(Statistic $statistic, $appId)
{
$this->driver::create($statistic->toArray());
}
}

View File

@ -1,90 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Statistics\Logger;
use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
class NullStatisticsLogger implements StatisticsLogger
{
/**
* The Channel manager.
*
* @var \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager
*/
protected $channelManager;
/**
* The statistics driver instance.
*
* @var \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver
*/
protected $driver;
/**
* Initialize the logger.
*
* @param \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager $channelManager
* @param \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver $driver
* @return void
*/
public function __construct(ChannelManager $channelManager, StatisticsDriver $driver)
{
$this->channelManager = $channelManager;
$this->driver = $driver;
}
/**
* Handle the incoming websocket message.
*
* @param mixed $appId
* @return void
*/
public function webSocketMessage($appId)
{
//
}
/**
* Handle the incoming API message.
*
* @param mixed $appId
* @return void
*/
public function apiMessage($appId)
{
//
}
/**
* Handle the new conection.
*
* @param mixed $appId
* @return void
*/
public function connection($appId)
{
//
}
/**
* Handle disconnections.
*
* @param mixed $appId
* @return void
*/
public function disconnection($appId)
{
//
}
/**
* Save all the stored statistics.
*
* @return void
*/
public function save()
{
//
}
}

View File

@ -1,309 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Statistics\Logger;
use BeyondCode\LaravelWebSockets\Apps\App;
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface;
use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
use Illuminate\Cache\RedisLock;
use Illuminate\Support\Facades\Redis;
class RedisStatisticsLogger implements StatisticsLogger
{
/**
* The Channel manager.
*
* @var \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager
*/
protected $channelManager;
/**
* The statistics driver instance.
*
* @var \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver
*/
protected $driver;
/**
* The replicator client.
*
* @var ReplicationInterface
*/
protected $replicator;
/**
* The Redis manager instance.
*
* @var \Illuminate\Redis\RedisManager
*/
protected $redis;
/**
* Initialize the logger.
*
* @param \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager $channelManager
* @param \BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver $driver
* @return void
*/
public function __construct(ChannelManager $channelManager, StatisticsDriver $driver)
{
$this->channelManager = $channelManager;
$this->driver = $driver;
$this->replicator = app(ReplicationInterface::class);
$this->redis = Redis::connection(
config('websockets.replication.redis.connection', 'default')
);
}
/**
* Handle the incoming websocket message.
*
* @param mixed $appId
* @return void
*/
public function webSocketMessage($appId)
{
$this->ensureAppIsSet($appId)
->hincrby($this->getHash($appId), 'websocket_message_count', 1);
}
/**
* Handle the incoming API message.
*
* @param mixed $appId
* @return void
*/
public function apiMessage($appId)
{
$this->ensureAppIsSet($appId)
->hincrby($this->getHash($appId), 'api_message_count', 1);
}
/**
* Handle the new conection.
*
* @param mixed $appId
* @return void
*/
public function connection($appId)
{
// Increment the current connections count by 1.
$incremented = $this->ensureAppIsSet($appId)
->hincrby($this->getHash($appId), 'current_connection_count', 1);
$incremented->then(function ($currentConnectionCount) use ($appId) {
// Get the peak connections count from Redis.
$peakConnectionCount = $this->replicator
->getPublishClient()
->hget($this->getHash($appId), 'peak_connection_count');
$peakConnectionCount->then(function ($currentPeakConnectionCount) use ($currentConnectionCount, $appId) {
// Extract the greatest number between the current peak connection count
// and the current connection number.
$peakConnectionCount = is_null($currentPeakConnectionCount)
? $currentConnectionCount
: max($currentPeakConnectionCount, $currentConnectionCount);
// Then set it to the database.
$this->replicator
->getPublishClient()
->hset($this->getHash($appId), 'peak_connection_count', $peakConnectionCount);
});
});
}
/**
* Handle disconnections.
*
* @param mixed $appId
* @return void
*/
public function disconnection($appId)
{
// Decrement the current connections count by 1.
$decremented = $this->ensureAppIsSet($appId)
->hincrby($this->getHash($appId), 'current_connection_count', -1);
$decremented->then(function ($currentConnectionCount) use ($appId) {
// Get the peak connections count from Redis.
$peakConnectionCount = $this->replicator
->getPublishClient()
->hget($this->getHash($appId), 'peak_connection_count');
$peakConnectionCount->then(function ($currentPeakConnectionCount) use ($currentConnectionCount, $appId) {
// Extract the greatest number between the current peak connection count
// and the current connection number.
$peakConnectionCount = is_null($currentPeakConnectionCount)
? $currentConnectionCount
: max($currentPeakConnectionCount, $currentConnectionCount);
// Then set it to the database.
$this->replicator
->getPublishClient()
->hset($this->getHash($appId), 'peak_connection_count', $peakConnectionCount);
});
});
}
/**
* Save all the stored statistics.
*
* @return void
*/
public function save()
{
$this->lock()->get(function () {
$setMembers = $this->replicator
->getPublishClient()
->smembers('laravel-websockets:apps');
$setMembers->then(function ($members) {
foreach ($members as $appId) {
$member = $this->replicator
->getPublishClient()
->hgetall($this->getHash($appId));
$member->then(function ($statistic) use ($appId) {
if (! $statistic) {
return;
}
// Statistics come into a list where the keys are on even indexes
// and the values are on odd indexes. This way, we know which
// ones are keys and which ones are values and their get combined
// later to form the key => value array
[$keys, $values] = collect($statistic)->partition(function ($value, $key) {
return $key % 2 === 0;
});
$statistic = array_combine($keys->all(), $values->all());
$this->createRecord($statistic, $appId);
$this->channelManager
->getGlobalConnectionsCount($appId)
->then(function ($currentConnectionCount) use ($appId) {
$currentConnectionCount === 0 || is_null($currentConnectionCount)
? $this->resetAppTraces($appId)
: $this->resetStatistics($appId, $currentConnectionCount);
});
});
}
});
});
}
/**
* Ensure the app id is stored in the Redis database.
*
* @param mixed $appId
* @return \Illuminate\Redis\RedisManager
*/
protected function ensureAppIsSet($appId)
{
$this->replicator
->getPublishClient()
->sadd('laravel-websockets:apps', $appId);
return $this->replicator->getPublishClient();
}
/**
* Reset the statistics to a specific connection count.
*
* @param mixed $appId
* @param int $currentConnectionCount
* @return void
*/
public function resetStatistics($appId, int $currentConnectionCount)
{
$this->replicator
->getPublishClient()
->hset($this->getHash($appId), 'current_connection_count', $currentConnectionCount);
$this->replicator
->getPublishClient()
->hset($this->getHash($appId), 'peak_connection_count', $currentConnectionCount);
$this->replicator
->getPublishClient()
->hset($this->getHash($appId), 'websocket_message_count', 0);
$this->replicator
->getPublishClient()
->hset($this->getHash($appId), 'api_message_count', 0);
}
/**
* Remove all app traces from the database if no connections have been set
* in the meanwhile since last save.
*
* @param mixed $appId
* @return void
*/
public function resetAppTraces($appId)
{
$this->replicator
->getPublishClient()
->hdel($this->getHash($appId), 'current_connection_count');
$this->replicator
->getPublishClient()
->hdel($this->getHash($appId), 'peak_connection_count');
$this->replicator
->getPublishClient()
->hdel($this->getHash($appId), 'websocket_message_count');
$this->replicator
->getPublishClient()
->hdel($this->getHash($appId), 'api_message_count');
$this->replicator
->getPublishClient()
->srem('laravel-websockets:apps', $appId);
}
/**
* Get the Redis hash name for the app.
*
* @param mixed $appId
* @return string
*/
protected function getHash($appId): string
{
return "laravel-websockets:app:{$appId}";
}
/**
* Get a new RedisLock instance to avoid race conditions.
*
* @return \Illuminate\Cache\CacheLock
*/
protected function lock()
{
return new RedisLock($this->redis, 'laravel-websockets:lock', 0);
}
/**
* Create a new record using the Statistic Driver.
*
* @param array $statistic
* @param mixed $appId
* @return void
*/
protected function createRecord(array $statistic, $appId): void
{
$this->driver::create([
'app_id' => $appId,
'peak_connection_count' => $statistic['peak_connection_count'] ?? 0,
'websocket_message_count' => $statistic['websocket_message_count'] ?? 0,
'api_message_count' => $statistic['api_message_count'] ?? 0,
]);
}
}

View File

@ -1,45 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Statistics\Logger;
interface StatisticsLogger
{
/**
* Handle the incoming websocket message.
*
* @param mixed $appId
* @return void
*/
public function webSocketMessage($appId);
/**
* Handle the incoming API message.
*
* @param mixed $appId
* @return void
*/
public function apiMessage($appId);
/**
* Handle the new conection.
*
* @param mixed $appId
* @return void
*/
public function connection($appId);
/**
* Handle disconnections.
*
* @param mixed $appId
* @return void
*/
public function disconnection($appId);
/**
* Save all the stored statistics.
*
* @return void
*/
public function save();
}

View File

@ -18,33 +18,33 @@ class Statistic
* *
* @var int * @var int
*/ */
protected $currentConnectionCount = 0; protected $currentConnectionsCount = 0;
/** /**
* The peak connections count ticker. * The peak connections count ticker.
* *
* @var int * @var int
*/ */
protected $peakConnectionCount = 0; protected $peakConnectionsCount = 0;
/** /**
* The websockets connections count ticker. * The websockets connections count ticker.
* *
* @var int * @var int
*/ */
protected $webSocketMessageCount = 0; protected $webSocketMessagesCount = 0;
/** /**
* The api messages connections count ticker. * The api messages connections count ticker.
* *
* @var int * @var int
*/ */
protected $apiMessageCount = 0; protected $apiMessagesCount = 0;
/** /**
* Create a new statistic. * Create a new statistic.
* *
* @param mixed $appId * @param string|int $appId
* @return void * @return void
*/ */
public function __construct($appId) public function __construct($appId)
@ -52,6 +52,58 @@ class Statistic
$this->appId = $appId; $this->appId = $appId;
} }
/**
* Set the current connections count.
*
* @param int $currentConnectionsCount
* @return $this
*/
public function setCurrentConnectionsCount(int $currentConnectionsCount)
{
$this->currentConnectionsCount = $currentConnectionsCount;
return $this;
}
/**
* Set the peak connections count.
*
* @param int $peakConnectionsCount
* @return $this
*/
public function setPeakConnectionsCount(int $peakConnectionsCount)
{
$this->peakConnectionsCount = $peakConnectionsCount;
return $this;
}
/**
* Set the peak connections count.
*
* @param int $webSocketMessagesCount
* @return $this
*/
public function setWebSocketMessagesCount(int $webSocketMessagesCount)
{
$this->webSocketMessagesCount = $webSocketMessagesCount;
return $this;
}
/**
* Set the peak connections count.
*
* @param int $apiMessagesCount
* @return $this
*/
public function setApiMessagesCount(int $apiMessagesCount)
{
$this->apiMessagesCount = $apiMessagesCount;
return $this;
}
/** /**
* Check if the app has statistics enabled. * Check if the app has statistics enabled.
* *
@ -69,9 +121,9 @@ class Statistic
*/ */
public function connection() public function connection()
{ {
$this->currentConnectionCount++; $this->currentConnectionsCount++;
$this->peakConnectionCount = max($this->currentConnectionCount, $this->peakConnectionCount); $this->peakConnectionsCount = max($this->currentConnectionsCount, $this->peakConnectionsCount);
} }
/** /**
@ -81,9 +133,9 @@ class Statistic
*/ */
public function disconnection() public function disconnection()
{ {
$this->currentConnectionCount--; $this->currentConnectionsCount--;
$this->peakConnectionCount = max($this->currentConnectionCount, $this->peakConnectionCount); $this->peakConnectionsCount = max($this->currentConnectionsCount, $this->peakConnectionsCount);
} }
/** /**
@ -93,7 +145,7 @@ class Statistic
*/ */
public function webSocketMessage() public function webSocketMessage()
{ {
$this->webSocketMessageCount++; $this->webSocketMessagesCount++;
} }
/** /**
@ -103,21 +155,21 @@ class Statistic
*/ */
public function apiMessage() public function apiMessage()
{ {
$this->apiMessageCount++; $this->apiMessagesCount++;
} }
/** /**
* Reset all the connections to a specific count. * Reset all the connections to a specific count.
* *
* @param int $currentConnectionCount * @param int $currentConnectionsCount
* @return void * @return void
*/ */
public function reset(int $currentConnectionCount) public function reset(int $currentConnectionsCount)
{ {
$this->currentConnectionCount = $currentConnectionCount; $this->currentConnectionsCount = $currentConnectionsCount;
$this->peakConnectionCount = $currentConnectionCount; $this->peakConnectionsCount = $currentConnectionsCount;
$this->webSocketMessageCount = 0; $this->webSocketMessagesCount = 0;
$this->apiMessageCount = 0; $this->apiMessagesCount = 0;
} }
/** /**
@ -129,9 +181,9 @@ class Statistic
{ {
return [ return [
'app_id' => $this->appId, 'app_id' => $this->appId,
'peak_connection_count' => $this->peakConnectionCount, 'peak_connections_count' => $this->peakConnectionsCount,
'websocket_message_count' => $this->webSocketMessageCount, 'websocket_messages_count' => $this->webSocketMessagesCount,
'api_message_count' => $this->apiMessageCount, 'api_messages_count' => $this->apiMessagesCount,
]; ];
} }
} }

View File

@ -0,0 +1,116 @@
<?php
namespace BeyondCode\LaravelWebSockets\Statistics\Stores;
use BeyondCode\LaravelWebSockets\Contracts\StatisticsStore;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
class DatabaseStore implements StatisticsStore
{
/**
* The model that will interact with the database.
*
* @var string
*/
protected static $model = \BeyondCode\LaravelWebSockets\Models\WebSocketsStatisticsEntry::class;
/**
* Store a new record in the database and return
* the created instance.
*
* @param array $data
* @return mixed
*/
public static function store(array $data)
{
return static::$model::create($data);
}
/**
* Delete records older than the given moment,
* for a specific app id (if given), returning
* the amount of deleted records.
*
* @param \Carbon\Carbon $moment
* @param string|int|null $appId
* @return int
*/
public static function delete(Carbon $moment, $appId = null): int
{
return static::$model::where('created_at', '<', $moment->toDateTimeString())
->when(! is_null($appId), function ($query) use ($appId) {
return $query->whereAppId($appId);
})
->delete();
}
/**
* Get the query result as eloquent collection.
*
* @param callable $processQuery
* @return \Illuminate\Support\Collection
*/
public function getRawRecords(callable $processQuery = null)
{
return static::$model::query()
->when(! is_null($processQuery), function ($query) use ($processQuery) {
return call_user_func($processQuery, $query);
}, function ($query) {
return $query->latest()->limit(120);
})->get();
}
/**
* Get the results for a specific query.
*
* @param callable $processQuery
* @param callable $processCollection
* @return array
*/
public function getRecords(callable $processQuery = null, callable $processCollection = null): array
{
return $this->getRawRecords($processQuery)
->when(! is_null($processCollection), function ($collection) use ($processCollection) {
return call_user_func($processCollection, $collection);
})
->map(function (Model $statistic) {
return [
'timestamp' => (string) $statistic->created_at,
'peak_connections_count' => $statistic->peak_connections_count,
'websocket_messages_count' => $statistic->websocket_messages_count,
'api_messages_count' => $statistic->api_messages_count,
];
})
->toArray();
}
/**
* Get the results for a specific query into a
* format that is easily to read for graphs.
*
* @param callable $processQuery
* @return array
*/
public function getForGraph(callable $processQuery = null): array
{
$statistics = collect(
$this->getRecords($processQuery)
);
return [
'peak_connections' => [
'x' => $statistics->pluck('timestamp')->toArray(),
'y' => $statistics->pluck('peak_connections_count')->toArray(),
],
'websocket_messages_count' => [
'x' => $statistics->pluck('timestamp')->toArray(),
'y' => $statistics->pluck('websocket_messages_count')->toArray(),
],
'api_messages_count' => [
'x' => $statistics->pluck('timestamp')->toArray(),
'y' => $statistics->pluck('api_messages_count')->toArray(),
],
];
}
}

View File

@ -1,254 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Channels;
use BeyondCode\LaravelWebSockets\Dashboard\DashboardLogger;
use BeyondCode\LaravelWebSockets\Events\MessagesBroadcasted;
use BeyondCode\LaravelWebSockets\Events\Subscribed;
use BeyondCode\LaravelWebSockets\Events\Unsubscribed;
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\InvalidSignature;
use Illuminate\Support\Str;
use Ratchet\ConnectionInterface;
use stdClass;
class Channel
{
/**
* The channel name.
*
* @var string
*/
protected $channelName;
/**
* The replicator client.
*
* @var ReplicationInterface
*/
protected $replicator;
/**
* The connections that got subscribed.
*
* @var array
*/
protected $subscribedConnections = [];
/**
* Create a new instance.
*
* @param string $channelName
* @return void
*/
public function __construct(string $channelName)
{
$this->channelName = $channelName;
$this->replicator = app(ReplicationInterface::class);
}
/**
* Get the channel name.
*
* @return string
*/
public function getChannelName(): string
{
return $this->channelName;
}
/**
* Check if the channel has connections.
*
* @return bool
*/
public function hasConnections(): bool
{
return count($this->subscribedConnections) > 0;
}
/**
* Get all subscribed connections.
*
* @return array
*/
public function getSubscribedConnections(): array
{
return $this->subscribedConnections;
}
/**
* Check if the signature for the payload is valid.
*
* @param \Ratchet\ConnectionInterface $connection
* @param \stdClass $payload
* @return void
* @throws InvalidSignature
*/
protected function verifySignature(ConnectionInterface $connection, stdClass $payload)
{
$signature = "{$connection->socketId}:{$this->channelName}";
if (isset($payload->channel_data)) {
$signature .= ":{$payload->channel_data}";
}
if (! hash_equals(
hash_hmac('sha256', $signature, $connection->app->secret),
Str::after($payload->auth, ':'))
) {
throw new InvalidSignature();
}
}
/**
* Subscribe to the channel.
*
* @see https://pusher.com/docs/pusher_protocol#presence-channel-events
* @param \Ratchet\ConnectionInterface $connection
* @param \stdClass $payload
* @return void
*/
public function subscribe(ConnectionInterface $connection, stdClass $payload)
{
$this->saveConnection($connection);
$connection->send(json_encode([
'event' => 'pusher_internal:subscription_succeeded',
'channel' => $this->channelName,
]));
$this->replicator->subscribe($connection->app->id, $this->channelName);
Subscribed::dispatch($this->channelName, $connection);
}
/**
* Unsubscribe connection from the channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @return void
*/
public function unsubscribe(ConnectionInterface $connection)
{
unset($this->subscribedConnections[$connection->socketId]);
$this->replicator->unsubscribe($connection->app->id, $this->channelName);
if (! $this->hasConnections()) {
DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_VACATED, [
'socketId' => $connection->socketId,
'channel' => $this->channelName,
]);
}
Unsubscribed::dispatch($this->channelName, $connection);
}
/**
* Store the connection to the subscribers list.
*
* @param \Ratchet\ConnectionInterface $connection
* @return void
*/
protected function saveConnection(ConnectionInterface $connection)
{
$hadConnectionsPreviously = $this->hasConnections();
$this->subscribedConnections[$connection->socketId] = $connection;
if (! $hadConnectionsPreviously) {
DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_OCCUPIED, [
'channel' => $this->channelName,
]);
}
DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_SUBSCRIBED, [
'socketId' => $connection->socketId,
'channel' => $this->channelName,
]);
}
/**
* Broadcast a payload to the subscribed connections.
*
* @param \stdClass $payload
* @return void
*/
public function broadcast($payload)
{
foreach ($this->subscribedConnections as $connection) {
$connection->send(json_encode($payload));
}
MessagesBroadcasted::dispatch(count($this->subscribedConnections));
}
/**
* Broadcast the payload, but exclude the current connection.
*
* @param \Ratchet\ConnectionInterface $connection
* @param \stdClass $payload
* @return void
*/
public function broadcastToOthers(ConnectionInterface $connection, stdClass $payload)
{
$this->broadcastToEveryoneExcept(
$payload, $connection->socketId, $connection->app->id
);
}
/**
* Broadcast the payload, but exclude a specific socket id.
*
* @param \stdClass $payload
* @param string|null $socketId
* @param mixed $appId
* @param bool $publish
* @return void
*/
public function broadcastToEveryoneExcept(stdClass $payload, ?string $socketId, $appId, bool $publish = true)
{
// Also broadcast via the other websocket server instances.
// This is set false in the Redis client because we don't want to cause a loop
// in this case. If this came from TriggerEventController, then we still want
// to publish to get the message out to other server instances.
if ($publish) {
$this->replicator->publish($appId, $this->channelName, $payload);
}
// Performance optimization, if we don't have a socket ID,
// then we avoid running the if condition in the foreach loop below
// by calling broadcast() instead.
if (is_null($socketId)) {
$this->broadcast($payload);
return;
}
$connections = collect($this->subscribedConnections)
->reject(function ($connection) use ($socketId) {
return $connection->socketId === $socketId;
});
foreach ($connections as $connection) {
$connection->send(json_encode($payload));
}
MessagesBroadcasted::dispatch($connections->count());
}
/**
* Convert the channel to array.
*
* @param mixed $appId
* @return array
*/
public function toArray($appId = null)
{
return [
'occupied' => count($this->subscribedConnections) > 0,
'subscription_count' => count($this->subscribedConnections),
];
}
}

View File

@ -1,58 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Channels;
use Ratchet\ConnectionInterface;
interface ChannelManager
{
/**
* Find a channel by name or create one.
*
* @param mixed $appId
* @param string $channelName
* @return \BeyondCode\LaravelWebSockets\WebSockets\Channels\Channel
*/
public function findOrCreate($appId, string $channelName): Channel;
/**
* Find a channel by name.
*
* @param mixed $appId
* @param string $channelName
* @return \BeyondCode\LaravelWebSockets\WebSockets\Channels\Channel
*/
public function find($appId, string $channelName): ?Channel;
/**
* Get all channels.
*
* @param mixed $appId
* @return array
*/
public function getChannels($appId): array;
/**
* Get the connections count on the app.
*
* @param mixed $appId
* @return int|\React\Promise\PromiseInterface
*/
public function getLocalConnectionsCount($appId): int;
/**
* Get the connections count across multiple servers.
*
* @param mixed $appId
* @return int|\React\Promise\PromiseInterface
*/
public function getGlobalConnectionsCount($appId);
/**
* Remove connection from all channels.
*
* @param \Ratchet\ConnectionInterface $connection
* @return void
*/
public function removeFromAllChannels(ConnectionInterface $connection);
}

View File

@ -1,141 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\Channel;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\PresenceChannel;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\PrivateChannel;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Ratchet\ConnectionInterface;
class ArrayChannelManager implements ChannelManager
{
/**
* The app id.
*
* @var mixed
*/
protected $appId;
/**
* The list of channels.
*
* @var array
*/
protected $channels = [];
/**
* Find a channel by name or create one.
*
* @param mixed $appId
* @param string $channelName
* @return \BeyondCode\LaravelWebSockets\WebSockets\Channels
*/
public function findOrCreate($appId, string $channelName): Channel
{
if (! isset($this->channels[$appId][$channelName])) {
$channelClass = $this->determineChannelClass($channelName);
$this->channels[$appId][$channelName] = new $channelClass($channelName);
}
return $this->channels[$appId][$channelName];
}
/**
* Find a channel by name.
*
* @param mixed $appId
* @param string $channelName
* @return \BeyondCode\LaravelWebSockets\WebSockets\Channels
*/
public function find($appId, string $channelName): ?Channel
{
return $this->channels[$appId][$channelName] ?? null;
}
/**
* Get all channels.
*
* @param mixed $appId
* @return array
*/
public function getChannels($appId): array
{
return $this->channels[$appId] ?? [];
}
/**
* Get the connections count on the app.
*
* @param mixed $appId
* @return int|\React\Promise\PromiseInterface
*/
public function getLocalConnectionsCount($appId): int
{
return collect($this->getChannels($appId))
->flatMap(function (Channel $channel) {
return collect($channel->getSubscribedConnections())->pluck('socketId');
})
->unique()
->count();
}
/**
* Get the connections count across multiple servers.
*
* @param mixed $appId
* @return int|\React\Promise\PromiseInterface
*/
public function getGlobalConnectionsCount($appId)
{
return $this->getLocalConnectionsCount($appId);
}
/**
* Remove connection from all channels.
*
* @param \Ratchet\ConnectionInterface $connection
* @return void
*/
public function removeFromAllChannels(ConnectionInterface $connection)
{
if (! isset($connection->app)) {
return;
}
collect(Arr::get($this->channels, $connection->app->id, []))
->each->unsubscribe($connection);
collect(Arr::get($this->channels, $connection->app->id, []))
->reject->hasConnections()
->each(function (Channel $channel, string $channelName) use ($connection) {
unset($this->channels[$connection->app->id][$channelName]);
});
if (count(Arr::get($this->channels, $connection->app->id, [])) === 0) {
unset($this->channels[$connection->app->id]);
}
}
/**
* Get the channel class by the channel name.
*
* @param string $channelName
* @return string
*/
protected function determineChannelClass(string $channelName): string
{
if (Str::startsWith($channelName, 'private-')) {
return PrivateChannel::class;
}
if (Str::startsWith($channelName, 'presence-')) {
return PresenceChannel::class;
}
return Channel::class;
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers;
use BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface;
class RedisChannelManager extends ArrayChannelManager
{
/**
* The replicator driver.
*
* @var \BeyondCode\LaravelWebSockets\PubSub\ReplicationInterface
*/
protected $replicator;
/**
* Initialize the channel manager.
*
* @return void
*/
public function __construct()
{
$this->replicator = app(ReplicationInterface::class);
}
/**
* Get the connections count across multiple servers.
*
* @param mixed $appId
* @return int|\React\Promise\PromiseInterface
*/
public function getGlobalConnectionsCount($appId)
{
return $this->replicator->getGlobalConnectionsCount($appId);
}
}

View File

@ -1,178 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Channels;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\InvalidSignature;
use Ratchet\ConnectionInterface;
use React\Promise\PromiseInterface;
use stdClass;
class PresenceChannel extends Channel
{
/**
* Data for the users connected to this channel.
*
* Note: If replication is enabled, this will only contain entries
* for the users directly connected to this server instance. Requests
* for data for all users in the channel should be routed through
* ReplicationInterface.
*
* @var string[]
*/
protected $users = [];
/**
* Get the members in the presence channel.
*
* @param string $appId
* @return PromiseInterface
*/
public function getUsers($appId)
{
return $this->replicator->channelMembers($appId, $this->channelName);
}
/**
* Subscribe the connection to the channel.
*
* @param ConnectionInterface $connection
* @param stdClass $payload
* @return void
* @throws InvalidSignature
* @see https://pusher.com/docs/pusher_protocol#presence-channel-events
*/
public function subscribe(ConnectionInterface $connection, stdClass $payload)
{
$this->verifySignature($connection, $payload);
$this->saveConnection($connection);
$channelData = json_decode($payload->channel_data);
$this->users[$connection->socketId] = $channelData;
// Add the connection as a member of the channel
$this->replicator->joinChannel(
$connection->app->id,
$this->channelName,
$connection->socketId,
json_encode($channelData)
);
// We need to pull the channel data from the replication backend,
// otherwise we won't be sending the full details of the channel
$this->replicator
->channelMembers($connection->app->id, $this->channelName)
->then(function ($users) use ($connection) {
$connection->send(json_encode([
'event' => 'pusher_internal:subscription_succeeded',
'channel' => $this->channelName,
'data' => json_encode($this->getChannelData($users)),
]));
});
$this->broadcastToOthers($connection, (object) [
'event' => 'pusher_internal:member_added',
'channel' => $this->channelName,
'data' => json_encode($channelData),
]);
}
/**
* Unsubscribe the connection from the Presence channel.
*
* @param ConnectionInterface $connection
* @return void
*/
public function unsubscribe(ConnectionInterface $connection)
{
parent::unsubscribe($connection);
if (! isset($this->users[$connection->socketId])) {
return;
}
// Remove the connection as a member of the channel
$this->replicator
->leaveChannel(
$connection->app->id,
$this->channelName,
$connection->socketId
);
$this->broadcastToOthers($connection, (object) [
'event' => 'pusher_internal:member_removed',
'channel' => $this->channelName,
'data' => json_encode([
'user_id' => $this->users[$connection->socketId]->user_id,
]),
]);
unset($this->users[$connection->socketId]);
}
/**
* Get the Presence Channel to array.
*
* @param string|null $appId
* @return PromiseInterface
*/
public function toArray($appId = null)
{
return $this->replicator
->channelMembers($appId, $this->channelName)
->then(function ($users) {
return array_merge(parent::toArray(), [
'user_count' => count($users),
]);
});
}
/**
* Get the Presence channel data.
*
* @param array $users
* @return array
*/
protected function getChannelData(array $users): array
{
return [
'presence' => [
'ids' => $this->getUserIds($users),
'hash' => $this->getHash($users),
'count' => count($users),
],
];
}
/**
* Get the Presence Channel's users.
*
* @param array $users
* @return array
*/
protected function getUserIds(array $users): array
{
$userIds = array_map(function ($channelData) {
return (string) $channelData->user_id;
}, $users);
return array_values($userIds);
}
/**
* Compute the hash for the presence channel integrity.
*
* @param array $users
* @return array
*/
protected function getHash(array $users): array
{
$hash = [];
foreach ($users as $socketId => $channelData) {
$hash[$channelData->user_id] = $channelData->user_info ?? [];
}
return $hash;
}
}

View File

@ -1,18 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Exceptions;
class InvalidConnection extends WebSocketException
{
/**
* Initialize the instance.
*
* @see https://pusher.com/docs/pusher_protocol#error-codes
* @return void
*/
public function __construct()
{
$this->message = 'Invalid Connection';
$this->code = 4009;
}
}

View File

@ -1,18 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Exceptions;
class OriginNotAllowed extends WebSocketException
{
/**
* Initialize the instance.
*
* @see https://pusher.com/docs/pusher_protocol#error-codes
* @return void
*/
public function __construct(string $appKey)
{
$this->message = "The origin is not allowed for `{$appKey}`.";
$this->code = 4009;
}
}

View File

@ -1,13 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\WebSockets\Exceptions;
class UnknownAppKey extends WebSocketException
{
public function __construct($appKey)
{
$this->message = "Could not find app key `{$appKey}`.";
$this->code = 4001;
}
}

View File

@ -2,19 +2,15 @@
namespace BeyondCode\LaravelWebSockets; namespace BeyondCode\LaravelWebSockets;
use BeyondCode\LaravelWebSockets\Apps\AppManager; use Illuminate\Support\ServiceProvider;
use BeyondCode\LaravelWebSockets\Server\Router;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Middleware\Authorize as AuthorizeDashboard;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\AuthenticateDashboard; use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\AuthenticateDashboard;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\SendMessage; use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\SendMessage;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\ShowDashboard; use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\ShowDashboard;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\ShowStatistics; use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\ShowStatistics;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Middleware\Authorize as AuthorizeDashboard;
use BeyondCode\LaravelWebSockets\Server\Router;
use BeyondCode\LaravelWebSockets\Statistics\Drivers\StatisticsDriver;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManager;
use BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\ArrayChannelManager;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
class WebSocketsServiceProvider extends ServiceProvider class WebSocketsServiceProvider extends ServiceProvider
{ {
@ -26,23 +22,20 @@ class WebSocketsServiceProvider extends ServiceProvider
public function boot() public function boot()
{ {
$this->publishes([ $this->publishes([
__DIR__.'/../config/websockets.php' => base_path('config/websockets.php'), __DIR__.'/../config/websockets.php' => config_path('websockets.php'),
], 'config'); ], 'config');
$this->mergeConfigFrom(
__DIR__.'/../config/websockets.php', 'websockets'
);
$this->publishes([ $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_create_websockets_statistics_entries_table.php' => database_path('migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php'),
], 'migrations'); ], 'migrations');
$this->registerDashboardRoutes() $this->registerDashboard();
->registerDashboardGate();
$this->loadViewsFrom(__DIR__.'/../resources/views/', 'websockets'); $this->registerCommands();
$this->commands([
Console\StartWebSocketServer::class,
Console\CleanStatistics::class,
Console\RestartWebSocketServer::class,
]);
} }
/** /**
@ -52,34 +45,59 @@ class WebSocketsServiceProvider extends ServiceProvider
*/ */
public function register() public function register()
{ {
$this->mergeConfigFrom(__DIR__.'/../config/websockets.php', 'websockets'); $this->registerRouter();
$this->registerManagers();
}
/**
* Regsiter the dashboard components.
*
* @return void
*/
protected function registerDashboard()
{
$this->loadViewsFrom(__DIR__.'/../resources/views/', 'websockets');
$this->registerDashboardRoutes();
$this->registerDashboardGate();
}
/**
* Register the package commands.
*
* @return void
*/
protected function registerCommands()
{
$this->commands([
Console\Commands\StartServer::class,
Console\Commands\RestartServer::class,
Console\Commands\CleanStatistics::class,
]);
}
/**
* Register the routing.
*
* @return void
*/
protected function registerRouter()
{
$this->app->singleton('websockets.router', function () { $this->app->singleton('websockets.router', function () {
return new Router(); return new Router;
}); });
}
$this->app->singleton(ChannelManager::class, function () { /**
$replicationDriver = config('websockets.replication.driver', 'local'); * Register the managers for the app.
*
$class = config("websockets.replication.{$replicationDriver}.channel_manager", ArrayChannelManager::class); * @return void
*/
return new $class; protected function registerManagers()
}); {
$this->app->singleton(Contracts\AppManager::class, function () {
$this->app->singleton(AppManager::class, function () {
return $this->app->make(config('websockets.managers.app')); return $this->app->make(config('websockets.managers.app'));
}); });
$this->app->singleton(StatisticsDriver::class, function () {
$driver = config('websockets.statistics.driver', 'local');
return $this->app->make(
config(
"websockets.statistics.{$driver}.driver",
\BeyondCode\LaravelWebSockets\Statistics\Drivers\DatabaseDriver::class
)
);
});
} }
/** /**
@ -99,8 +117,6 @@ class WebSocketsServiceProvider extends ServiceProvider
Route::post('/auth', AuthenticateDashboard::class)->name('auth'); Route::post('/auth', AuthenticateDashboard::class)->name('auth');
Route::post('/event', SendMessage::class)->name('event'); Route::post('/event', SendMessage::class)->name('event');
}); });
return $this;
} }
/** /**
@ -113,7 +129,5 @@ class WebSocketsServiceProvider extends ServiceProvider
Gate::define('viewWebSocketsDashboard', function ($user = null) { Gate::define('viewWebSocketsDashboard', function ($user = null) {
return $this->app->environment('local'); return $this->app->environment('local');
}); });
return $this;
} }
} }

View File

@ -1,158 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Tests\Channels;
use BeyondCode\LaravelWebSockets\Tests\Mocks\Message;
use BeyondCode\LaravelWebSockets\Tests\TestCase;
class ChannelReplicationTest extends TestCase
{
/**
* {@inheritdoc}
*/
public function setUp(): void
{
parent::setUp();
$this->runOnlyOnRedisReplication();
}
/** @test */
public function replication_clients_can_subscribe_to_channels()
{
$connection = $this->getWebSocketConnection();
$message = new Message([
'event' => 'pusher:subscribe',
'data' => [
'channel' => 'basic-channel',
],
]);
$this->pusherServer->onOpen($connection);
$this->pusherServer->onMessage($connection, $message);
$connection->assertSentEvent('pusher_internal:subscription_succeeded', [
'channel' => 'basic-channel',
]);
}
/** @test */
public function replication_clients_can_unsubscribe_from_channels()
{
$connection = $this->getConnectedWebSocketConnection(['test-channel']);
$channel = $this->getChannel($connection, 'test-channel');
$this->assertTrue($channel->hasConnections());
$message = new Message([
'event' => 'pusher:unsubscribe',
'data' => [
'channel' => 'test-channel',
],
]);
$this->pusherServer->onMessage($connection, $message);
$this->assertFalse($channel->hasConnections());
}
/** @test */
public function replication_a_client_cannot_broadcast_to_other_clients_by_default()
{
// One connection inside channel "test-channel".
$existingConnection = $this->getConnectedWebSocketConnection(['test-channel']);
$connection = $this->getConnectedWebSocketConnection(['test-channel']);
$message = new Message(['event' => 'client-test', 'data' => [], 'channel' => 'test-channel']);
$this->pusherServer->onMessage($connection, $message);
$existingConnection->assertNotSentEvent('client-test');
}
/** @test */
public function replication_a_client_can_be_enabled_to_broadcast_to_other_clients()
{
config()->set('websockets.apps.0.enable_client_messages', true);
// One connection inside channel "test-channel".
$existingConnection = $this->getConnectedWebSocketConnection(['test-channel']);
$connection = $this->getConnectedWebSocketConnection(['test-channel']);
$message = new Message(['event' => 'client-test', 'data' => [], 'channel' => 'test-channel']);
$this->pusherServer->onMessage($connection, $message);
$existingConnection->assertSentEvent('client-test');
}
/** @test */
public function replication_closed_connections_get_removed_from_all_connected_channels()
{
$connection = $this->getConnectedWebSocketConnection(['test-channel-1', 'test-channel-2']);
$channel1 = $this->getChannel($connection, 'test-channel-1');
$channel2 = $this->getChannel($connection, 'test-channel-2');
$this->assertTrue($channel1->hasConnections());
$this->assertTrue($channel2->hasConnections());
$this->pusherServer->onClose($connection);
$this->assertFalse($channel1->hasConnections());
$this->assertFalse($channel2->hasConnections());
}
/** @test */
public function replication_channels_can_broadcast_messages_to_all_connections()
{
$connection1 = $this->getConnectedWebSocketConnection(['test-channel']);
$connection2 = $this->getConnectedWebSocketConnection(['test-channel']);
$channel = $this->getChannel($connection1, 'test-channel');
$channel->broadcast([
'event' => 'broadcasted-event',
'channel' => 'test-channel',
]);
$connection1->assertSentEvent('broadcasted-event');
$connection2->assertSentEvent('broadcasted-event');
}
/** @test */
public function replication_channels_can_broadcast_messages_to_all_connections_except_the_given_connection()
{
$connection1 = $this->getConnectedWebSocketConnection(['test-channel']);
$connection2 = $this->getConnectedWebSocketConnection(['test-channel']);
$channel = $this->getChannel($connection1, 'test-channel');
$channel->broadcastToOthers($connection1, (object) [
'event' => 'broadcasted-event',
'channel' => 'test-channel',
]);
$connection1->assertNotSentEvent('broadcasted-event');
$connection2->assertSentEvent('broadcasted-event');
}
/** @test */
public function replication_it_responds_correctly_to_the_ping_message()
{
$connection = $this->getConnectedWebSocketConnection();
$message = new Message([
'event' => 'pusher:ping',
]);
$this->pusherServer->onMessage($connection, $message);
$connection->assertSentEvent('pusher:pong');
}
}

View File

@ -1,148 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Tests\Channels;
use BeyondCode\LaravelWebSockets\Tests\Mocks\Message;
use BeyondCode\LaravelWebSockets\Tests\TestCase;
class ChannelTest extends TestCase
{
/** @test */
public function clients_can_subscribe_to_channels()
{
$connection = $this->getWebSocketConnection();
$message = new Message([
'event' => 'pusher:subscribe',
'data' => [
'channel' => 'basic-channel',
],
]);
$this->pusherServer->onOpen($connection);
$this->pusherServer->onMessage($connection, $message);
$connection->assertSentEvent('pusher_internal:subscription_succeeded', [
'channel' => 'basic-channel',
]);
}
/** @test */
public function clients_can_unsubscribe_from_channels()
{
$connection = $this->getConnectedWebSocketConnection(['test-channel']);
$channel = $this->getChannel($connection, 'test-channel');
$this->assertTrue($channel->hasConnections());
$message = new Message([
'event' => 'pusher:unsubscribe',
'data' => [
'channel' => 'test-channel',
],
]);
$this->pusherServer->onMessage($connection, $message);
$this->assertFalse($channel->hasConnections());
}
/** @test */
public function a_client_cannot_broadcast_to_other_clients_by_default()
{
// One connection inside channel "test-channel".
$existingConnection = $this->getConnectedWebSocketConnection(['test-channel']);
$connection = $this->getConnectedWebSocketConnection(['test-channel']);
$message = new Message(['event' => 'client-test', 'data' => [], 'channel' => 'test-channel']);
$this->pusherServer->onMessage($connection, $message);
$existingConnection->assertNotSentEvent('client-test');
}
/** @test */
public function a_client_can_be_enabled_to_broadcast_to_other_clients()
{
config()->set('websockets.apps.0.enable_client_messages', true);
// One connection inside channel "test-channel".
$existingConnection = $this->getConnectedWebSocketConnection(['test-channel']);
$connection = $this->getConnectedWebSocketConnection(['test-channel']);
$message = new Message(['event' => 'client-test', 'data' => [], 'channel' => 'test-channel']);
$this->pusherServer->onMessage($connection, $message);
$existingConnection->assertSentEvent('client-test');
}
/** @test */
public function closed_connections_get_removed_from_all_connected_channels()
{
$connection = $this->getConnectedWebSocketConnection(['test-channel-1', 'test-channel-2']);
$channel1 = $this->getChannel($connection, 'test-channel-1');
$channel2 = $this->getChannel($connection, 'test-channel-2');
$this->assertTrue($channel1->hasConnections());
$this->assertTrue($channel2->hasConnections());
$this->pusherServer->onClose($connection);
$this->assertFalse($channel1->hasConnections());
$this->assertFalse($channel2->hasConnections());
}
/** @test */
public function channels_can_broadcast_messages_to_all_connections()
{
$connection1 = $this->getConnectedWebSocketConnection(['test-channel']);
$connection2 = $this->getConnectedWebSocketConnection(['test-channel']);
$channel = $this->getChannel($connection1, 'test-channel');
$channel->broadcast([
'event' => 'broadcasted-event',
'channel' => 'test-channel',
]);
$connection1->assertSentEvent('broadcasted-event');
$connection2->assertSentEvent('broadcasted-event');
}
/** @test */
public function channels_can_broadcast_messages_to_all_connections_except_the_given_connection()
{
$connection1 = $this->getConnectedWebSocketConnection(['test-channel']);
$connection2 = $this->getConnectedWebSocketConnection(['test-channel']);
$channel = $this->getChannel($connection1, 'test-channel');
$channel->broadcastToOthers($connection1, (object) [
'event' => 'broadcasted-event',
'channel' => 'test-channel',
]);
$connection1->assertNotSentEvent('broadcasted-event');
$connection2->assertSentEvent('broadcasted-event');
}
/** @test */
public function it_responds_correctly_to_the_ping_message()
{
$connection = $this->getConnectedWebSocketConnection();
$message = new Message([
'event' => 'pusher:ping',
]);
$this->pusherServer->onMessage($connection, $message);
$connection->assertSentEvent('pusher:pong');
}
}

View File

@ -1,140 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Tests\Channels;
use BeyondCode\LaravelWebSockets\Tests\Mocks\Message;
use BeyondCode\LaravelWebSockets\Tests\TestCase;
class PresenceChannelReplicationTest extends TestCase
{
/**
* {@inheritdoc}
*/
public function setUp(): void
{
parent::setUp();
$this->runOnlyOnRedisReplication();
}
/** @test */
public function clients_with_valid_auth_signatures_can_join_presence_channels()
{
$connection = $this->getWebSocketConnection();
$this->pusherServer->onOpen($connection);
$channelData = [
'user_id' => 1,
'user_info' => [
'name' => 'Marcel',
],
];
$signature = "{$connection->socketId}:presence-channel:".json_encode($channelData);
$message = new Message([
'event' => 'pusher:subscribe',
'data' => [
'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret),
'channel' => 'presence-channel',
'channel_data' => json_encode($channelData),
],
]);
$this->pusherServer->onMessage($connection, $message);
$this->getPublishClient()
->assertCalledWithArgs('hset', [
'laravel_database_1234:presence-channel',
$connection->socketId,
json_encode($channelData),
])
->assertCalledWithArgs('hgetall', ['laravel_database_1234:presence-channel'])
->assertCalled('publish');
$this->assertNotNull(
$this->redis->hget('laravel_database_1234:presence-channel', $connection->socketId)
);
}
/** @test */
public function clients_with_valid_auth_signatures_can_leave_presence_channels()
{
$connection = $this->getWebSocketConnection();
$this->pusherServer->onOpen($connection);
$channelData = [
'user_id' => 1,
];
$signature = "{$connection->socketId}:presence-channel:".json_encode($channelData);
$message = new Message([
'event' => 'pusher:subscribe',
'data' => [
'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret),
'channel' => 'presence-channel',
'channel_data' => json_encode($channelData),
],
]);
$this->pusherServer->onMessage($connection, $message);
$this->getSubscribeClient()
->assertEventDispatched('message');
$this->getPublishClient()
->assertCalled('hset')
->assertCalledWithArgs('hgetall', ['laravel_database_1234:presence-channel'])
->assertCalled('publish');
$this->getPublishClient()
->resetAssertions();
$message = new Message([
'event' => 'pusher:unsubscribe',
'data' => [
'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret),
'channel' => 'presence-channel',
],
]);
$this->pusherServer->onMessage($connection, $message);
$this->getPublishClient()
->assertCalled('hdel')
->assertCalled('publish');
}
/** @test */
public function clients_with_no_user_info_can_join_presence_channels()
{
$connection = $this->getWebSocketConnection();
$this->pusherServer->onOpen($connection);
$channelData = [
'user_id' => 1,
];
$signature = "{$connection->socketId}:presence-channel:".json_encode($channelData);
$message = new Message([
'event' => 'pusher:subscribe',
'data' => [
'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret),
'channel' => 'presence-channel',
'channel_data' => json_encode($channelData),
],
]);
$this->pusherServer->onMessage($connection, $message);
$this->getPublishClient()
->assertCalled('hset')
->assertCalledWithArgs('hgetall', ['laravel_database_1234:presence-channel'])
->assertCalled('publish');
}
}

View File

@ -1,165 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Tests\Channels;
use BeyondCode\LaravelWebSockets\Tests\Mocks\Message;
use BeyondCode\LaravelWebSockets\Tests\TestCase;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\InvalidSignature;
class PresenceChannelTest extends TestCase
{
/** @test */
public function clients_need_valid_auth_signatures_to_join_presence_channels()
{
$this->expectException(InvalidSignature::class);
$connection = $this->getWebSocketConnection();
$message = new Message([
'event' => 'pusher:subscribe',
'data' => [
'auth' => 'invalid',
'channel' => 'presence-channel',
],
]);
$this->pusherServer->onOpen($connection);
$this->pusherServer->onMessage($connection, $message);
}
/** @test */
public function clients_with_valid_auth_signatures_can_join_presence_channels()
{
$this->skipOnRedisReplication();
$connection = $this->getWebSocketConnection();
$this->pusherServer->onOpen($connection);
$channelData = [
'user_id' => 1,
'user_info' => [
'name' => 'Marcel',
],
];
$signature = "{$connection->socketId}:presence-channel:".json_encode($channelData);
$message = new Message([
'event' => 'pusher:subscribe',
'data' => [
'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret),
'channel' => 'presence-channel',
'channel_data' => json_encode($channelData),
],
]);
$this->pusherServer->onMessage($connection, $message);
$connection->assertSentEvent('pusher_internal:subscription_succeeded', [
'channel' => 'presence-channel',
]);
}
/** @test */
public function clients_with_valid_auth_signatures_can_leave_presence_channels()
{
$this->skipOnRedisReplication();
$connection = $this->getWebSocketConnection();
$this->pusherServer->onOpen($connection);
$channelData = [
'user_id' => 1,
];
$signature = "{$connection->socketId}:presence-channel:".json_encode($channelData);
$message = new Message([
'event' => 'pusher:subscribe',
'data' => [
'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret),
'channel' => 'presence-channel',
'channel_data' => json_encode($channelData),
],
]);
$this->pusherServer->onMessage($connection, $message);
$connection->assertSentEvent('pusher_internal:subscription_succeeded', [
'channel' => 'presence-channel',
]);
$message = new Message([
'event' => 'pusher:unsubscribe',
'data' => [
'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret),
'channel' => 'presence-channel',
],
]);
$this->pusherServer->onMessage($connection, $message);
}
/** @test */
public function clients_with_no_user_info_can_join_presence_channels()
{
$this->skipOnRedisReplication();
$connection = $this->getWebSocketConnection();
$this->pusherServer->onOpen($connection);
$channelData = [
'user_id' => 1,
];
$signature = "{$connection->socketId}:presence-channel:".json_encode($channelData);
$message = new Message([
'event' => 'pusher:subscribe',
'data' => [
'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret),
'channel' => 'presence-channel',
'channel_data' => json_encode($channelData),
],
]);
$this->pusherServer->onMessage($connection, $message);
$connection->assertSentEvent('pusher_internal:subscription_succeeded', [
'channel' => 'presence-channel',
]);
}
/** @test */
public function clients_with_valid_auth_signatures_cannot_leave_channels_they_are_not_in()
{
$connection = $this->getWebSocketConnection();
$this->pusherServer->onOpen($connection);
$channelData = [
'user_id' => 1,
'user_info' => [
'name' => 'Marcel',
],
];
$signature = "{$connection->socketId}:presence-channel:".json_encode($channelData);
$message = new Message([
'event' => 'pusher:unsubscribe',
'data' => [
'auth' => $connection->app->key.':'.hash_hmac('sha256', $signature, $connection->app->secret),
'channel' => 'presence-channel',
],
]);
$this->pusherServer->onMessage($connection, $message);
$this->assertTrue(true);
}
}

View File

@ -1,66 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Tests\Channels;
use BeyondCode\LaravelWebSockets\Tests\Mocks\Message;
use BeyondCode\LaravelWebSockets\Tests\TestCase;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\InvalidSignature;
class PrivateChannelReplicationTest extends TestCase
{
/**
* {@inheritdoc}
*/
public function setUp(): void
{
parent::setUp();
$this->runOnlyOnRedisReplication();
}
/** @test */
public function replication_clients_need_valid_auth_signatures_to_join_private_channels()
{
$this->expectException(InvalidSignature::class);
$connection = $this->getWebSocketConnection();
$message = new Message([
'event' => 'pusher:subscribe',
'data' => [
'auth' => 'invalid',
'channel' => 'private-channel',
],
]);
$this->pusherServer->onOpen($connection);
$this->pusherServer->onMessage($connection, $message);
}
/** @test */
public function replication_clients_with_valid_auth_signatures_can_join_private_channels()
{
$connection = $this->getWebSocketConnection();
$this->pusherServer->onOpen($connection);
$signature = "{$connection->socketId}:private-channel";
$hashedAppSecret = hash_hmac('sha256', $signature, $connection->app->secret);
$message = new Message([
'event' => 'pusher:subscribe',
'data' => [
'auth' => "{$connection->app->key}:{$hashedAppSecret}",
'channel' => 'private-channel',
],
]);
$this->pusherServer->onMessage($connection, $message);
$connection->assertSentEvent('pusher_internal:subscription_succeeded', [
'channel' => 'private-channel',
]);
}
}

View File

@ -1,56 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Tests\Channels;
use BeyondCode\LaravelWebSockets\Tests\Mocks\Message;
use BeyondCode\LaravelWebSockets\Tests\TestCase;
use BeyondCode\LaravelWebSockets\WebSockets\Exceptions\InvalidSignature;
class PrivateChannelTest extends TestCase
{
/** @test */
public function clients_need_valid_auth_signatures_to_join_private_channels()
{
$this->expectException(InvalidSignature::class);
$connection = $this->getWebSocketConnection();
$message = new Message([
'event' => 'pusher:subscribe',
'data' => [
'auth' => 'invalid',
'channel' => 'private-channel',
],
]);
$this->pusherServer->onOpen($connection);
$this->pusherServer->onMessage($connection, $message);
}
/** @test */
public function clients_with_valid_auth_signatures_can_join_private_channels()
{
$connection = $this->getWebSocketConnection();
$this->pusherServer->onOpen($connection);
$signature = "{$connection->socketId}:private-channel";
$hashedAppSecret = hash_hmac('sha256', $signature, $connection->app->secret);
$message = new Message([
'event' => 'pusher:subscribe',
'data' => [
'auth' => "{$connection->app->key}:{$hashedAppSecret}",
'channel' => 'private-channel',
],
]);
$this->pusherServer->onMessage($connection, $message);
$connection->assertSentEvent('pusher_internal:subscription_succeeded', [
'channel' => 'private-channel',
]);
}
}

View File

@ -1,34 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Tests\ClientProviders;
use BeyondCode\LaravelWebSockets\Apps\App;
use BeyondCode\LaravelWebSockets\Exceptions\InvalidApp;
use BeyondCode\LaravelWebSockets\Tests\TestCase;
class AppTest extends TestCase
{
/** @test */
public function it_can_create_a_client()
{
new App(1, 'appKey', 'appSecret');
$this->assertTrue(true);
}
/** @test */
public function it_will_not_accept_an_empty_appKey()
{
$this->expectException(InvalidApp::class);
new App(1, '', 'appSecret');
}
/** @test */
public function it_will_not_accept_an_empty_appSecret()
{
$this->expectException(InvalidApp::class);
new App(1, 'appKey', '');
}
}

View File

@ -1,88 +0,0 @@
<?php
namespace BeyondCode\LaravelWebSockets\Tests\ClientProviders;
use BeyondCode\LaravelWebSockets\Apps\ConfigAppManager;
use BeyondCode\LaravelWebSockets\Tests\TestCase;
class ConfigAppManagerTest extends TestCase
{
/** @var \BeyondCode\LaravelWebSockets\Apps\ConfigAppManager */
protected $appManager;
public function setUp(): void
{
parent::setUp();
$this->appManager = new ConfigAppManager;
}
/** @test */
public function it_can_get_apps_from_the_config_file()
{
$apps = $this->appManager->all();
$this->assertCount(2, $apps);
/** @var $app */
$app = $apps[0];
$this->assertEquals('Test App', $app->name);
$this->assertEquals(1234, $app->id);
$this->assertEquals('TestKey', $app->key);
$this->assertEquals('TestSecret', $app->secret);
$this->assertFalse($app->clientMessagesEnabled);
$this->assertTrue($app->statisticsEnabled);
}
/** @test */
public function it_can_find_app_by_id()
{
$app = $this->appManager->findById(0000);
$this->assertNull($app);
$app = $this->appManager->findById(1234);
$this->assertEquals('Test App', $app->name);
$this->assertEquals(1234, $app->id);
$this->assertEquals('TestKey', $app->key);
$this->assertEquals('TestSecret', $app->secret);
$this->assertFalse($app->clientMessagesEnabled);
$this->assertTrue($app->statisticsEnabled);
}
/** @test */
public function it_can_find_app_by_key()
{
$app = $this->appManager->findByKey('InvalidKey');
$this->assertNull($app);
$app = $this->appManager->findByKey('TestKey');
$this->assertEquals('Test App', $app->name);
$this->assertEquals(1234, $app->id);
$this->assertEquals('TestKey', $app->key);
$this->assertEquals('TestSecret', $app->secret);
$this->assertFalse($app->clientMessagesEnabled);
$this->assertTrue($app->statisticsEnabled);
}
/** @test */
public function it_can_find_app_by_secret()
{
$app = $this->appManager->findBySecret('InvalidSecret');
$this->assertNull($app);
$app = $this->appManager->findBySecret('TestSecret');
$this->assertEquals('Test App', $app->name);
$this->assertEquals(1234, $app->id);
$this->assertEquals('TestKey', $app->key);
$this->assertEquals('TestSecret', $app->secret);
$this->assertFalse($app->clientMessagesEnabled);
$this->assertTrue($app->statisticsEnabled);
}
}

Some files were not shown because too many files have changed in this diff Show More