Merge 2.x changes (#1043)

* Resolve conflicts

* Apply fixes from StyleCI (#1042)
This commit is contained in:
Marcel Pociot 2022-10-06 13:46:54 +02:00 committed by GitHub
parent c53e78d5a0
commit fb958fb851
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 2136 additions and 780 deletions

View File

@ -20,41 +20,16 @@ jobs:
fail-fast: false
matrix:
php:
- '7.3'
- '7.4'
- '8.0'
- '8.1'
laravel:
- 6.*
- 7.*
- 8.*
- 9.*
prefer:
- 'prefer-lowest'
- 'prefer-stable'
include:
- laravel: '6.*'
testbench: '4.*'
phpunit: '^8.5.8|^9.3.3'
- laravel: '7.*'
testbench: '5.*'
phpunit: '^8.5.8|^9.3.3'
- laravel: '8.*'
testbench: '6.*'
phpunit: '^9.3.3'
exclude:
- php: '8.0'
laravel: 6.*
prefer: 'prefer-lowest'
- php: '8.0'
laravel: 7.*
prefer: 'prefer-lowest'
- php: '8.1'
laravel: 6.*
- php: '8.1'
laravel: 7.*
- php: '8.1'
laravel: 8.*
prefer: 'prefer-lowest'
- laravel: '9.*'
testbench: '7.*'
name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} --${{ matrix.prefer }}
@ -68,6 +43,13 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: pcov
- name: Setup MySQL
uses: haltuf/mysql-action@master
with:
mysql version: '8.0'
mysql database: 'websockets_test'
mysql root password: 'password'
- name: Setup Redis
uses: supercharge/redis-github-action@1.1.0
with:
@ -81,7 +63,7 @@ jobs:
- name: Install dependencies
run: |
composer require "laravel/framework:${{ matrix.laravel }}" "phpunit/phpunit:${{ matrix.phpunit }}" "orchestra/testbench-browser-kit:${{ matrix.testbench }}" "orchestra/database:${{ matrix.testbench }}" --no-interaction --no-update
composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench-browser-kit:${{ matrix.testbench }}" "orchestra/database:${{ matrix.testbench }}" --no-interaction --no-update
composer update --${{ matrix.prefer }} --prefer-dist --no-interaction --no-suggest
- name: Run tests for Local

View File

@ -29,27 +29,33 @@
}
],
"require": {
"cboden/ratchet": "^0.4.1",
"clue/redis-react": "^2.5",
"php": "^8.0|^8.1",
"cboden/ratchet": "^0.4.4",
"clue/block-react": "^1.5",
"clue/reactphp-sqlite": "^1.0",
"clue/redis-react": "^2.6",
"doctrine/dbal": "^2.9",
"evenement/evenement": "^2.0|^3.0",
"facade/ignition-contracts": "^1.0",
"guzzlehttp/psr7": "^1.7|^2.0",
"illuminate/broadcasting": "^6.3|^7.0|^8.0|^9.0",
"illuminate/console": "^6.3|^7.0|^8.0|^9.0",
"illuminate/http": "^6.3|^7.0|^8.0|^9.0",
"illuminate/queue": "^6.3|^7.0|^8.0|^9.0",
"illuminate/routing": "^6.3|^7.0|^8.0|^9.0",
"illuminate/support": "^6.3|^7.0|^8.0|^9.0",
"pusher/pusher-php-server": "^3.0|^4.0|^5.0|^6.0|^7.0",
"guzzlehttp/psr7": "^1.5",
"illuminate/broadcasting": "^9.0",
"illuminate/console": "^9.0",
"illuminate/http": "^9.0",
"illuminate/queue": "^9.0",
"illuminate/routing": "^9.0",
"illuminate/support": "^9.0",
"pusher/pusher-php-server": "^6.0|^7.0",
"react/mysql": "^0.5",
"react/promise": "^2.8",
"symfony/http-kernel": "^4.4|^5.4|^6.0",
"symfony/http-kernel": "^5.0|^6.0",
"symfony/psr-http-message-bridge": "^1.1|^2.0"
},
"require-dev": {
"clue/block-react": "^1.4",
"clue/buzz-react": "^2.9",
"laravel/legacy-factories": "^1.1",
"orchestra/testbench-browser-kit": "^4.0|^5.0|^6.0",
"phpunit/phpunit": "^8.5.8|^9.3.3"
"orchestra/testbench-browser-kit": "^7.0",
"phpunit/phpunit": "^9.0",
"ratchet/pawl": "^0.3.5"
},
"suggest": {
"ext-pcntl": "Running the server needs pcntl to listen to command signals and soft-shutdown.",
@ -71,7 +77,8 @@
"config": {
"sort-packages": true
},
"minimum-stability": "stable",
"minimum-stability": "dev",
"prefer-stable": true,
"extra": {
"laravel": {
"providers": [

View File

@ -37,13 +37,40 @@ return [
| the use of the TCP protocol based on, for example, a list of allowed
| applications.
| By default, it uses the defined array in the config file, but you can
| anytime implement the same interface as the class and add your own
| custom method to retrieve the apps.
| choose to use SQLite or MySQL application managers, or define a
| custom application manager.
|
*/
'app' => \BeyondCode\LaravelWebSockets\Apps\ConfigAppManager::class,
/*
|--------------------------------------------------------------------------
| SQLite application manager
|--------------------------------------------------------------------------
|
| The SQLite database to use when using the SQLite application manager.
|
*/
'sqlite' => [
'database' => storage_path('laravel-websockets.sqlite'),
],
/*
|--------------------------------------------------------------------------
| MySql application manager
|--------------------------------------------------------------------------
|
| The MySQL database connection to use.
|
*/
'mysql' => [
'connection' => env('DB_CONNECTION', 'mysql'),
'table' => 'websockets_apps',
],
],
/*

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateWebSocketsAppsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('websockets_apps', function (Blueprint $table) {
$table->string('id')->index();
$table->string('key');
$table->string('secret');
$table->string('name');
$table->string('host')->nullable();
$table->string('path')->nullable();
$table->boolean('enable_client_messages')->default(false);
$table->boolean('enable_statistics')->default(true);
$table->unsignedInteger('capacity')->nullable();
$table->string('allowed_origins');
$table->nullableTimestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('websockets_apps');
}
}

View File

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

View File

@ -1,44 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class RenameStatisticsCounters extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('websockets_statistics_entries', function (Blueprint $table) {
$table->renameColumn('peak_connection_count', 'peak_connections_count');
});
Schema::table('websockets_statistics_entries', function (Blueprint $table) {
$table->renameColumn('websocket_message_count', 'websocket_messages_count');
});
Schema::table('websockets_statistics_entries', function (Blueprint $table) {
$table->renameColumn('api_message_count', 'api_messages_count');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('websockets_statistics_entries', function (Blueprint $table) {
$table->renameColumn('peak_connections_count', 'peak_connection_count');
});
Schema::table('websockets_statistics_entries', function (Blueprint $table) {
$table->renameColumn('websocket_messages_count', 'websocket_message_count');
});
Schema::table('websockets_statistics_entries', function (Blueprint $table) {
$table->renameColumn('api_messages_count', 'api_message_count');
});
}
}

View File

@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS apps (
id STRING NOT NULL,
key STRING NOT NULL,
secret STRING NOT NULL,
name STRING NOT NULL,
host STRING NULLABLE,
path STRING NULLABLE,
enable_client_messages BOOLEAN DEFAULT 0,
enable_statistics BOOLEAN DEFAULT 1,
capacity INTEGER NULLABLE,
allowed_origins STRING NULLABLE
)

View File

@ -0,0 +1,183 @@
@extends('websockets::layout')
@section('title')
Apps
@endsection
@section('content')
<div class="flex flex-col py-8" id="app">
<form action="{{ route('laravel-websockets.apps.store') }}" method="POST">
@csrf
<div>
<div>
<div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Add new app
</h3>
</div>
@if($errors->isNotEmpty())
<div class="bg-red-500 text-white my-4 p-4 rounded">
@foreach($errors->all() as $error)
{{ $error }}<br>
@endforeach
</div>
@endif
<div class="mt-6 sm:mt-5">
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label for="name"
class="block text-sm font-medium leading-5 text-gray-700 sm:mt-px sm:pt-2">
Name
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="max-w-lg flex rounded-md shadow-sm">
<input id="name" name="name"
class="flex-1 form-input block w-full rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5"/>
</div>
</div>
</div>
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:pt-5">
<label for="allowed_origins"
class="block text-sm font-medium leading-5 text-gray-700 sm:mt-px sm:pt-2">
Allowed origins (comma separated)
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="max-w-lg flex rounded-md shadow-sm">
<input id="allowed_origins" name="allowed_origins"
class="flex-1 form-input block w-full rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5"/>
</div>
</div>
</div>
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:pt-5">
<label for="enable_statistics"
class="block text-sm font-medium leading-5 text-gray-700 sm:mt-px sm:pt-2">
Enable Statistics
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="mt-2 flex items-center justify-between">
<div class="flex items-center">
<input id="enable_statistics"
name="enable_statistics"
value="1" type="checkbox" class="form-checkbox h-4 w-4 text-indigo-600 transition duration-150 ease-in-out" />
<label for="enable_statistics" class="ml-2 block text-sm leading-5 text-gray-900">
Yes
</label>
</div>
</div>
</div>
</div>
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:pt-5">
<label for="enable_client_messages"
class="block text-sm font-medium leading-5 text-gray-700 sm:mt-px sm:pt-2">
Enable Client Messages
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="mt-2 flex items-center justify-between">
<div class="flex items-center">
<input id="enable_client_messages"
name="enable_client_messages"
value="1" type="checkbox" class="form-checkbox h-4 w-4 text-indigo-600 transition duration-150 ease-in-out" />
<label for="enable_client_messages" class="ml-2 block text-sm leading-5 text-gray-900">
Yes
</label>
</div>
</div>
</div>
</div>
</div>
<div class="mt-8 border-t border-gray-200 pt-5">
<div class="flex justify-end">
<span class="ml-3 inline-flex rounded-md shadow-sm">
<button type="submit"
@click.prevent="saveUser"
class="inline-flex justify-center py-2 px-4 border border-transparent text-sm leading-5 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:shadow-outline-indigo active:bg-indigo-700 transition duration-150 ease-in-out">
Save
</button>
</span>
</div>
</div>
</div>
</div>
</form>
<div class="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div
class="align-middle inline-block min-w-full shadow overflow-hidden sm:rounded-lg border-b border-gray-200">
<table class="min-w-full" v-if="apps.length > 0">
<thead>
<tr>
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
Allowed origins
</th>
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
Statistics
</th>
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
Client Messages
</th>
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50"></th>
</tr>
</thead>
<tbody class="bg-white">
<tr v-for="app in apps">
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 font-medium text-gray-900">
@{{ app.name }}
</td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
@{{ app.allowed_origins || '*' }}
</td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
<span v-if="app.enable_statistics">Yes</span>
<span v-else>No</span>
</td>
<td class="px-6 py-4 whitespace-no-wrap border-b border-gray-200 text-sm leading-5 text-gray-500">
<span v-if="app.enable_client_messages">Yes</span>
<span v-else>No</span>
</td>
<td class="px-6 py-4 whitespace-no-wrap text-right border-b border-gray-200 text-sm leading-5 font-medium">
<a href="#" @click.prevent="showInstructions(app)"
class="pl-4 text-gray-600 hover:text-gray-900">Installation instructions</a>
<a href="#" @click.prevent="deleteUser(user)"
class="pl-4 text-red-600 hover:text-red-900">Delete</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="py-4" v-if="app">
<p class="pb-1">Modify your <code>.env</code> file:</p>
<pre class="bg-gray-100 p-4 rounded">PUSHER_APP_HOST=@{{ app.host === null ? window.location.hostname : app.host }}
PUSHER_APP_PORT={{ $port }}
PUSHER_APP_KEY=@{{ app.key }}
PUSHER_APP_ID=@{{ app.id }}
PUSHER_APP_SECRET=@{{ app.secret }}
PUSHER_APP_SCHEME=https
MIX_PUSHER_APP_HOST="${PUSHER_APP_HOST}"
MIX_PUSHER_APP_PORT="${PUSHER_APP_PORT}"
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"</pre>
</div>
</div>
@endsection
@section('scripts')
<script>
new Vue({
el: '#app',
data: {
app: null,
apps: @json($apps),
},
methods: {
showInstructions(app) {
this.app = app;
}
}
});
</script>
@endsection

View File

@ -1,32 +1,13 @@
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=ISO-8859-1">
@extends('websockets::layout')
<title>WebSockets Dashboard</title>
@section('title')
Dashboard
@endsection
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/vue-json-editor@1.4.2/assets/jsoneditor.min.css" rel="stylesheet">
<script src="https://js.pusher.com/7.0/pusher.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js"></script>
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/v-jsoneditor@1.4.1/dist/v-jsoneditor.min.js"></script>
<script>
window.baseURL = '{{ url(request()->path()) }}';
axios.defaults.baseURL = baseURL;
</script>
</head>
<body class="px-6">
@section('content')
<div
id="app"
class="mx-auto"
:class="{
'max-w-xl': ! connected,
'max-w-6xl': connected,
}"
>
<div class="w-full my-6 rounded-lg bg-gray-100 p-6">
<div class="font-semibold uppercase text-gray-700 mb-6">
@ -235,7 +216,10 @@
</div>
</div>
</div>
<script>
@endsection
@section('scripts')
<script>
new Vue({
el: '#app',
data: {
@ -447,6 +431,5 @@
},
},
});
</script>
</body>
</html>
</script>
@endsection

View File

@ -0,0 +1,93 @@
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=ISO-8859-1">
<title>Laravel WebSockets</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tailwindcss/ui@latest/dist/tailwind-ui.min.css">
<link href="https://cdn.jsdelivr.net/npm/vue-json-editor@1.4.2/assets/jsoneditor.min.css" rel="stylesheet">
<script src="https://js.pusher.com/7.0/pusher.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js"></script>
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/v-jsoneditor@1.4.1/dist/v-jsoneditor.min.js"></script>
<script>
window.baseURL = '{{ url(request()->path()) }}';
axios.defaults.baseURL = baseURL;
</script>
</head>
<body>
<div class="min-h-screen bg-white">
<nav class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center font-bold">
Laravel WebSockets
</div>
<div class="hidden sm:-my-px sm:ml-6 sm:flex">
<a href="{{ route('laravel-websockets.dashboard') }}"
class="
@if(Route::is('laravel-websockets.dashboard'))
border-indigo-500 focus:border-indigo-700 text-gray-900
@else
border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:text-gray-700 focus:border-gray-300
@endif
inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 focus:outline-none transition duration-150 ease-in-out">
Dashboard
</a>
<a href="{{ route('laravel-websockets.apps') }}"
class="
@if(Route::is('laravel-websockets.apps'))
border-indigo-500 focus:border-indigo-700 text-gray-900
@else
border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:text-gray-700 focus:border-gray-300
@endif
ml-8 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium leading-5 focus:outline-none transition duration-150 ease-in-out">
Apps
</a>
</div>
</div>
<div class="-mr-2 flex items-center sm:hidden">
<!-- Mobile menu button -->
<button
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
<!-- Menu open: "hidden", Menu closed: "block" -->
<svg class="block h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"/>
</svg>
<!-- Menu open: "block", Menu closed: "hidden" -->
<svg class="hidden h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</div>
</nav>
<div class="py-10">
<header>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 class="text-3xl font-bold leading-tight text-gray-900">
@yield('title')
</h1>
</div>
</header>
<main>
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
@yield('content')
</div>
</main>
</div>
</div>
@yield('scripts')
</body>
</html>

View File

@ -17,6 +17,7 @@ use Psr\Http\Message\RequestInterface;
use Pusher\Pusher;
use Ratchet\ConnectionInterface;
use Ratchet\Http\HttpServerInterface;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
use Symfony\Component\HttpKernel\Exception\HttpException;
@ -52,13 +53,6 @@ abstract class Controller implements HttpServerInterface
*/
protected $channelManager;
/**
* The app attached with this request.
*
* @var \BeyondCode\LaravelWebSockets\Apps\App|null
*/
protected $app;
/**
* Initialize the request.
*
@ -184,11 +178,25 @@ abstract class Controller implements HttpServerInterface
$laravelRequest = Request::createFromBase((new HttpFoundationFactory)->createRequest($serverRequest));
$this->ensureValidAppId($laravelRequest->get('appId'))
->ensureValidSignature($laravelRequest);
$this
->ensureValidAppId($laravelRequest->appId)
->then(function ($app) use ($laravelRequest, $connection) {
try {
$this->ensureValidSignature($app, $laravelRequest);
} catch (HttpException $exception) {
$this->onError($connection, $exception);
return;
}
// Invoke the controller action
try {
$response = $this($laravelRequest);
} catch (HttpException $exception) {
$this->onError($connection, $exception);
return;
}
// Allow for async IO in the controller action
if ($response instanceof PromiseInterface) {
@ -200,10 +208,13 @@ abstract class Controller implements HttpServerInterface
}
if ($response instanceof HttpException) {
throw $response;
$this->onError($connection, $response);
return;
}
$this->sendAndClose($connection, $response);
});
}
/**
@ -222,29 +233,34 @@ abstract class Controller implements HttpServerInterface
* Ensure app existence.
*
* @param mixed $appId
* @return $this
* @return PromiseInterface
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function ensureValidAppId($appId)
{
if (! $appId || ! $this->app = App::findById($appId)) {
$deferred = new Deferred();
App::findById($appId)
->then(function ($app) use ($appId, $deferred) {
if (! $app) {
throw new HttpException(401, "Unknown app id `{$appId}` provided.");
}
$deferred->resolve($app);
});
return $this;
return $deferred->promise();
}
/**
* Ensure signature integrity coming from an
* authorized application.
*
* @param \GuzzleHttp\Psr7\ServerRequest $request
* @param App $app
* @param Request $request
* @return $this
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
protected function ensureValidSignature(Request $request)
protected function ensureValidSignature(App $app, Request $request)
{
// The `auth_signature` & `body_md5` parameters are not included when calculating the `auth_signature` value.
// The `appId`, `appKey` & `channelName` parameters are actually route parameters and are never supplied by the client.
@ -261,7 +277,7 @@ abstract class Controller implements HttpServerInterface
$signature = "{$request->getMethod()}\n/{$request->path()}\n".Pusher::array_implode('=', '&', $params);
$authSignature = hash_hmac('sha256', $signature, $this->app->secret);
$authSignature = hash_hmac('sha256', $signature, $app->secret);
if ($authSignature !== $request->get('auth_signature')) {
throw new HttpException(401, 'Invalid auth signature provided.');

View File

@ -5,6 +5,7 @@ namespace BeyondCode\LaravelWebSockets\API;
use BeyondCode\LaravelWebSockets\DashboardLogger;
use BeyondCode\LaravelWebSockets\Facades\StatisticsCollector;
use Illuminate\Http\Request;
use React\Promise\Deferred;
class TriggerEvent extends Controller
{
@ -16,11 +17,15 @@ class TriggerEvent extends Controller
*/
public function __invoke(Request $request)
{
if ($request->has('channel')) {
$channels = [$request->get('channel')];
} else {
$channels = $request->channels ?: [];
if (is_string($channels)) {
$channels = [$channels];
}
}
foreach ($channels as $channelName) {
// Here you can use the ->find(), even if the channel
@ -49,7 +54,11 @@ class TriggerEvent extends Controller
$request->appId, $request->socket_id, $channelName, (object) $payload
);
if ($this->app->statisticsEnabled) {
$deferred = new Deferred();
$this->ensureValidAppId($request->appId)
->then(function ($app) use ($request, $channelName, $deferred) {
if ($app->statisticsEnabled) {
StatisticsCollector::apiMessage($request->appId);
}
@ -58,8 +67,11 @@ class TriggerEvent extends Controller
'channel' => $channelName,
'payload' => $request->data,
]);
$deferred->resolve((object) []);
});
}
return (object) [];
return $deferred->promise();
}
}

View File

@ -3,6 +3,7 @@
namespace BeyondCode\LaravelWebSockets\Apps;
use BeyondCode\LaravelWebSockets\Contracts\AppManager;
use React\Promise\PromiseInterface;
class App
{
@ -40,7 +41,7 @@ class App
* Find the app by id.
*
* @param string|int $appId
* @return \BeyondCode\LaravelWebSockets\Apps\App|null
* @return PromiseInterface
*/
public static function findById($appId)
{
@ -51,9 +52,9 @@ class App
* Find the app by app key.
*
* @param string $appKey
* @return \BeyondCode\LaravelWebSockets\Apps\App|null
* @return PromiseInterface
*/
public static function findByKey($appKey): ?self
public static function findByKey($appKey): PromiseInterface
{
return app(AppManager::class)->findByKey($appKey);
}
@ -62,9 +63,9 @@ class App
* Find the app by app secret.
*
* @param string $appSecret
* @return \BeyondCode\LaravelWebSockets\Apps\App|null
* @return PromiseInterface
*/
public static function findBySecret($appSecret): ?self
public static function findBySecret($appSecret): PromiseInterface
{
return app(AppManager::class)->findBySecret($appSecret);
}

View File

@ -3,6 +3,8 @@
namespace BeyondCode\LaravelWebSockets\Apps;
use BeyondCode\LaravelWebSockets\Contracts\AppManager;
use React\Promise\PromiseInterface;
use function React\Promise\resolve as resolvePromise;
class ConfigAppManager implements AppManager
{
@ -26,54 +28,64 @@ class ConfigAppManager implements AppManager
/**
* Get all apps.
*
* @return array[\BeyondCode\LaravelWebSockets\Apps\App]
* @return PromiseInterface
*/
public function all(): array
public function all(): PromiseInterface
{
return $this->apps
return resolvePromise($this->apps
->map(function (array $appAttributes) {
return $this->convertIntoApp($appAttributes);
})
->toArray();
->toArray());
}
/**
* Get app by id.
*
* @param string|int $appId
* @return \BeyondCode\LaravelWebSockets\Apps\App|null
* @return PromiseInterface
*/
public function findById($appId): ?App
public function findById($appId): PromiseInterface
{
return $this->convertIntoApp(
return resolvePromise($this->convertIntoApp(
$this->apps->firstWhere('id', $appId)
);
));
}
/**
* Get app by app key.
*
* @param string $appKey
* @return \BeyondCode\LaravelWebSockets\Apps\App|null
* @return PromiseInterface
*/
public function findByKey($appKey): ?App
public function findByKey($appKey): PromiseInterface
{
return $this->convertIntoApp(
return resolvePromise($this->convertIntoApp(
$this->apps->firstWhere('key', $appKey)
);
));
}
/**
* Get app by secret.
*
* @param string $appSecret
* @return \BeyondCode\LaravelWebSockets\Apps\App|null
* @return PromiseInterface
*/
public function findBySecret($appSecret): ?App
public function findBySecret($appSecret): PromiseInterface
{
return $this->convertIntoApp(
return resolvePromise($this->convertIntoApp(
$this->apps->firstWhere('secret', $appSecret)
);
));
}
/**
* @inheritDoc
*/
public function createApp($appData): PromiseInterface
{
$this->apps->push($appData);
return resolvePromise();
}
/**
@ -107,8 +119,8 @@ class ConfigAppManager implements AppManager
}
$app
->enableClientMessages($appAttributes['enable_client_messages'])
->enableStatistics($appAttributes['enable_statistics'])
->enableClientMessages((bool) $appAttributes['enable_client_messages'])
->enableStatistics((bool) $appAttributes['enable_statistics'])
->setCapacity($appAttributes['capacity'] ?? null)
->setAllowedOrigins($appAttributes['allowed_origins'] ?? []);

View File

@ -0,0 +1,171 @@
<?php
namespace BeyondCode\LaravelWebSockets\Apps;
use BeyondCode\LaravelWebSockets\Contracts\AppManager;
use React\MySQL\ConnectionInterface;
use React\MySQL\QueryResult;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
class MysqlAppManager implements AppManager
{
/**
* The database connection.
*
* @var ConnectionInterface
*/
protected $database;
/**
* Initialize the class.
*
* @param ConnectionInterface $database
*/
public function __construct(ConnectionInterface $database)
{
$this->database = $database;
}
protected function getTableName(): string
{
return config('websockets.managers.mysql.table');
}
/**
* Get all apps.
*
* @return PromiseInterface
*/
public function all(): PromiseInterface
{
$deferred = new Deferred();
$this->database->query('SELECT * FROM `'.$this->getTableName().'`')
->then(function (QueryResult $result) use ($deferred) {
$deferred->resolve($result->resultRows);
}, function ($error) use ($deferred) {
$deferred->reject($error);
});
return $deferred->promise();
}
/**
* Get app by id.
*
* @param string|int $appId
* @return PromiseInterface
*/
public function findById($appId): PromiseInterface
{
$deferred = new Deferred();
$this->database->query('SELECT * from `'.$this->getTableName().'` WHERE `id` = ?', [$appId])
->then(function (QueryResult $result) use ($deferred) {
$deferred->resolve($this->convertIntoApp($result->resultRows[0]));
}, function ($error) use ($deferred) {
$deferred->reject($error);
});
return $deferred->promise();
}
/**
* Get app by app key.
*
* @param string $appKey
* @return PromiseInterface
*/
public function findByKey($appKey): PromiseInterface
{
$deferred = new Deferred();
$this->database->query('SELECT * from `'.$this->getTableName().'` WHERE `key` = ?', [$appKey])
->then(function (QueryResult $result) use ($deferred) {
$deferred->resolve($this->convertIntoApp($result->resultRows[0]));
}, function ($error) use ($deferred) {
$deferred->reject($error);
});
return $deferred->promise();
}
/**
* Get app by secret.
*
* @param string $appSecret
* @return PromiseInterface
*/
public function findBySecret($appSecret): PromiseInterface
{
$deferred = new Deferred();
$this->database->query('SELECT * from `'.$this->getTableName().'` WHERE `secret` = ?', [$appSecret])
->then(function (QueryResult $result) use ($deferred) {
$deferred->resolve($this->convertIntoApp($result->resultRows[0]));
}, function ($error) use ($deferred) {
$deferred->reject($error);
});
return $deferred->promise();
}
/**
* Map the app into an App instance.
*
* @param array|null $app
* @return \BeyondCode\LaravelWebSockets\Apps\App|null
*/
protected function convertIntoApp(?array $appAttributes): ?App
{
if (! $appAttributes) {
return null;
}
$app = new App(
$appAttributes['id'],
$appAttributes['key'],
$appAttributes['secret']
);
if (isset($appAttributes['name'])) {
$app->setName($appAttributes['name']);
}
if (isset($appAttributes['host'])) {
$app->setHost($appAttributes['host']);
}
if (isset($appAttributes['path'])) {
$app->setPath($appAttributes['path']);
}
$app
->enableClientMessages((bool) $appAttributes['enable_client_messages'])
->enableStatistics((bool) $appAttributes['enable_statistics'])
->setCapacity($appAttributes['capacity'] ?? null)
->setAllowedOrigins(array_filter(explode(',', $appAttributes['allowed_origins'])));
return $app;
}
/**
* @inheritDoc
*/
public function createApp($appData): PromiseInterface
{
$deferred = new Deferred();
$this->database->query(
'INSERT INTO `'.$this->getTableName().'` (`id`, `key`, `secret`, `name`, `enable_client_messages`, `enable_statistics`, `allowed_origins`, `capacity`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[$appData['id'], $appData['key'], $appData['secret'], $appData['name'], $appData['enable_client_messages'], $appData['enable_statistics'], $appData['allowed_origins'] ?? '', $appData['capacity'] ?? null])
->then(function () use ($deferred) {
$deferred->resolve();
}, function ($error) use ($deferred) {
$deferred->reject($error);
});
return $deferred->promise();
}
}

View File

@ -0,0 +1,167 @@
<?php
namespace BeyondCode\LaravelWebSockets\Apps;
use BeyondCode\LaravelWebSockets\Contracts\AppManager;
use Clue\React\SQLite\DatabaseInterface;
use Clue\React\SQLite\Result;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
class SQLiteAppManager implements AppManager
{
/**
* The database connection.
*
* @var DatabaseInterface
*/
protected $database;
/**
* Initialize the class.
*
* @param DatabaseInterface $database
*/
public function __construct(DatabaseInterface $database)
{
$this->database = $database;
}
/**
* Get all apps.
*
* @return PromiseInterface
*/
public function all(): PromiseInterface
{
$deferred = new Deferred();
$this->database->query('SELECT * FROM `apps`')
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows);
}, function ($error) use ($deferred) {
$deferred->reject($error);
});
return $deferred->promise();
}
/**
* Get app by id.
*
* @param string|int $appId
* @return PromiseInterface
*/
public function findById($appId): PromiseInterface
{
$deferred = new Deferred();
$this->database->query('SELECT * from apps WHERE `id` = :id', ['id' => $appId])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($this->convertIntoApp($result->rows[0]));
}, function ($error) use ($deferred) {
$deferred->reject($error);
});
return $deferred->promise();
}
/**
* Get app by app key.
*
* @param string $appKey
* @return PromiseInterface
*/
public function findByKey($appKey): PromiseInterface
{
$deferred = new Deferred();
$this->database->query('SELECT * from apps WHERE `key` = :key', ['key' => $appKey])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($this->convertIntoApp($result->rows[0]));
}, function ($error) use ($deferred) {
$deferred->reject($error);
});
return $deferred->promise();
}
/**
* Get app by secret.
*
* @param string $appSecret
* @return PromiseInterface
*/
public function findBySecret($appSecret): PromiseInterface
{
$deferred = new Deferred();
$this->database->query('SELECT * from apps WHERE `secret` = :secret', ['secret' => $appSecret])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($this->convertIntoApp($result->rows[0]));
}, function ($error) use ($deferred) {
$deferred->reject($error);
});
return $deferred->promise();
}
/**
* Map the app into an App instance.
*
* @param array|null $app
* @return \BeyondCode\LaravelWebSockets\Apps\App|null
*/
protected function convertIntoApp(?array $appAttributes): ?App
{
if (! $appAttributes) {
return null;
}
$app = new App(
$appAttributes['id'],
$appAttributes['key'],
$appAttributes['secret']
);
if (isset($appAttributes['name'])) {
$app->setName($appAttributes['name']);
}
if (isset($appAttributes['host'])) {
$app->setHost($appAttributes['host']);
}
if (isset($appAttributes['path'])) {
$app->setPath($appAttributes['path']);
}
$app
->enableClientMessages((bool) $appAttributes['enable_client_messages'])
->enableStatistics((bool) $appAttributes['enable_statistics'])
->setCapacity($appAttributes['capacity'] ?? null)
->setAllowedOrigins(array_filter(explode(',', $appAttributes['allowed_origins'])));
return $app;
}
/**
* @inheritDoc
*/
public function createApp($appData): PromiseInterface
{
$deferred = new Deferred();
$this->database->query('
INSERT INTO apps (id, key, secret, name, host, path, enable_client_messages, enable_statistics, capacity, allowed_origins)
VALUES (:id, :key, :secret, :name, :host, :path, :enable_client_messages, :enable_statistics, :capacity, :allowed_origins)
', $appData)
->then(function (Result $result) use ($deferred) {
$deferred->resolve();
}, function ($error) use ($deferred) {
$deferred->reject($error);
});
return $deferred->promise();
}
}

55
src/Cache/ArrayLock.php Normal file
View File

@ -0,0 +1,55 @@
<?php
namespace BeyondCode\LaravelWebSockets\Cache;
use BeyondCode\LaravelWebSockets\Helpers;
use Illuminate\Cache\ArrayLock as LaravelLock;
use React\Promise\PromiseInterface;
class ArrayLock extends Lock
{
/**
* The parent array cache store.
*
* @var \Illuminate\Cache\ArrayStore
*/
protected $store;
/**
* Internal Laravel Array Lock.
*
* @var \Illuminate\Cache\ArrayLock
*/
protected $lock;
/**
* Create a new lock instance.
*
* @param \Illuminate\Cache\ArrayStore $store
* @param string $name
* @param int $seconds
* @param string|null $owner
* @return void
*/
public function __construct($store, $name, $seconds, $owner = null)
{
parent::__construct($name, $seconds, $owner);
$this->lock = new LaravelLock($store, $name, $seconds, $owner);
}
public function acquire(): PromiseInterface
{
return Helpers::createFulfilledPromise($this->lock->acquire());
}
public function get($callback = null): PromiseInterface
{
return $this->lock->get($callback);
}
public function release(): PromiseInterface
{
return Helpers::createFulfilledPromise($this->lock->release());
}
}

46
src/Cache/Lock.php Normal file
View File

@ -0,0 +1,46 @@
<?php
namespace BeyondCode\LaravelWebSockets\Cache;
use Illuminate\Support\Str;
use React\Promise\PromiseInterface;
abstract class Lock
{
/**
* The name of the lock.
*
* @var string
*/
protected $name;
/**
* The number of seconds the lock should be maintained.
*
* @var int
*/
protected $seconds;
/**
* The scope identifier of this lock.
*
* @var string
*/
protected $owner;
public function __construct($name, $seconds, $owner = null)
{
if (is_null($owner)) {
$owner = Str::random();
}
$this->name = $name;
$this->seconds = $seconds;
$this->owner = $owner;
}
abstract public function acquire(): PromiseInterface;
abstract public function get($callback = null): PromiseInterface;
abstract public function release(): PromiseInterface;
}

69
src/Cache/RedisLock.php Normal file
View File

@ -0,0 +1,69 @@
<?php
namespace BeyondCode\LaravelWebSockets\Cache;
use Clue\React\Redis\Client;
use Illuminate\Cache\LuaScripts;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
class RedisLock extends Lock
{
/**
* The asynchronous redis client.
*
* @var Client
*/
protected $redis;
public function __construct(Client $redis, $name, $seconds, $owner = null)
{
parent::__construct($name, $seconds, $owner);
$this->redis = $redis;
}
public function acquire(): PromiseInterface
{
$promise = new Deferred();
if ($this->seconds > 0) {
$this->redis
->set($this->name, $this->owner, 'EX', $this->seconds, 'NX')
->then(function ($result) use ($promise) {
$promise->resolve($result === true);
});
} else {
$this->redis
->setnx($this->name, $this->owner)
->then(function ($result) use ($promise) {
$promise->resolve($result === 1);
});
}
return $promise->promise();
}
public function get($callback = null): PromiseInterface
{
$promise = new Deferred();
$this->acquire()
->then(function ($result) use ($callback, $promise) {
if ($result) {
try {
$callback();
} finally {
$promise->resolve($this->release());
}
}
});
return $promise->promise();
}
public function release(): PromiseInterface
{
return $this->redis->eval(LuaScripts::releaseLock(), 1, $this->name, $this->owner);
}
}

View File

@ -2,17 +2,18 @@
namespace BeyondCode\LaravelWebSockets\ChannelManagers;
use BeyondCode\LaravelWebSockets\Cache\ArrayLock;
use BeyondCode\LaravelWebSockets\Channels\Channel;
use BeyondCode\LaravelWebSockets\Channels\PresenceChannel;
use BeyondCode\LaravelWebSockets\Channels\PrivateChannel;
use BeyondCode\LaravelWebSockets\Contracts\ChannelManager;
use BeyondCode\LaravelWebSockets\Helpers;
use Carbon\Carbon;
use Illuminate\Cache\ArrayLock;
use Illuminate\Cache\ArrayStore;
use Illuminate\Support\Str;
use Ratchet\ConnectionInterface;
use React\EventLoop\LoopInterface;
use function React\Promise\all;
use React\Promise\PromiseInterface;
use stdClass;
@ -226,9 +227,7 @@ class LocalChannelManager implements ChannelManager
{
$channel = $this->findOrCreate($connection->app->id, $channelName);
return Helpers::createFulfilledPromise(
$channel->unsubscribe($connection, $payload)
);
return $channel->unsubscribe($connection, $payload);
}
/**
@ -439,26 +438,24 @@ class LocalChannelManager implements ChannelManager
*/
public function removeObsoleteConnections(): PromiseInterface
{
$lock = $this->lock();
try {
if (! $lock->acquire()) {
return Helpers::createFulfilledPromise(false);
}
return $this->lock()->get(function () {
return $this->getLocalConnections()
->then(function ($connections) {
$promises = [];
$this->getLocalConnections()->then(function ($connections) {
foreach ($connections as $connection) {
$differenceInSeconds = $connection->lastPongedAt->diffInSeconds(Carbon::now());
if ($differenceInSeconds > 120) {
$this->unsubscribeFromAllChannels($connection);
$promises[] = $this->unsubscribeFromAllChannels($connection);
}
}
});
return Helpers::createFulfilledPromise(true);
} finally {
optional($lock)->forceRelease();
}
return all($promises);
})->then(function () {
$this->lock()->release();
});
});
}
/**
@ -557,7 +554,7 @@ class LocalChannelManager implements ChannelManager
/**
* Get a new ArrayLock instance to avoid race conditions.
*
* @return \Illuminate\Cache\CacheLock
* @return ArrayLock
*/
protected function lock()
{

View File

@ -2,17 +2,19 @@
namespace BeyondCode\LaravelWebSockets\ChannelManagers;
use BeyondCode\LaravelWebSockets\Cache\RedisLock;
use BeyondCode\LaravelWebSockets\Channels\Channel;
use BeyondCode\LaravelWebSockets\DashboardLogger;
use BeyondCode\LaravelWebSockets\Helpers;
use BeyondCode\LaravelWebSockets\Server\MockableConnection;
use Carbon\Carbon;
use Clue\React\Redis\Client;
use Clue\React\Redis\Factory;
use Illuminate\Cache\RedisLock;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
use Ratchet\ConnectionInterface;
use React\EventLoop\LoopInterface;
use function React\Promise\all;
use React\Promise\PromiseInterface;
use stdClass;
@ -100,9 +102,12 @@ class RedisChannelManager extends LocalChannelManager
{
return $this->getGlobalChannels($connection->app->id)
->then(function ($channels) use ($connection) {
$promises = [];
foreach ($channels as $channel) {
$this->unsubscribeFromChannel($connection, $channel, new stdClass);
$promises[] = $this->unsubscribeFromChannel($connection, $channel, new stdClass);
}
return all($promises);
})
->then(function () use ($connection) {
return parent::unsubscribeFromAllChannels($connection);
@ -144,19 +149,37 @@ class RedisChannelManager extends LocalChannelManager
*/
public function unsubscribeFromChannel(ConnectionInterface $connection, string $channelName, stdClass $payload): PromiseInterface
{
return parent::unsubscribeFromChannel($connection, $channelName, $payload)
->then(function () use ($connection, $channelName) {
return $this->decrementSubscriptionsCount($connection->app->id, $channelName);
})
return $this->getGlobalConnectionsCount($connection->app->id, $channelName)
->then(function ($count) use ($connection, $channelName) {
if ($count === 0) {
// Make sure to not stay subscribed to the PubSub topic
// if there are no connections.
return $this->unsubscribeFromTopic($connection->app->id, $channelName);
}
return Helpers::createFulfilledPromise(null);
})
->then(function () use ($connection, $channelName) {
return $this->decrementSubscriptionsCount($connection->app->id, $channelName)
->then(function ($count) use ($connection, $channelName) {
$this->removeConnectionFromSet($connection);
// If the total connections count gets to 0 after unsubscribe,
// try again to check & unsubscribe from the PubSub topic if needed.
if ($count < 1) {
$this->removeChannelFromSet($connection->app->id, $channelName);
$this->unsubscribeFromTopic($connection->app->id, $channelName);
$promises = [];
$promises[] = $this->unsubscribeFromTopic($connection->app->id, $channelName);
$promises[] = $this->removeChannelFromSet($connection->app->id, $channelName);
return all($promises);
}
});
})
->then(function () use ($connection) {
return $this->removeConnectionFromSet($connection);
})
->then(function () use ($connection, $channelName, $payload) {
return parent::unsubscribeFromChannel($connection, $channelName, $payload);
});
}
/**
@ -371,23 +394,21 @@ class RedisChannelManager extends LocalChannelManager
*/
public function removeObsoleteConnections(): PromiseInterface
{
$lock = $this->lock();
try {
$lock->get(function () {
$this->getConnectionsFromSet(0, now()->subMinutes(2)->format('U'))
return $this->lock()->get(function () {
return $this->getConnectionsFromSet(0, now()->subMinutes(2)->format('U'))
->then(function ($connections) {
$promises = [];
foreach ($connections as $socketId => $appId) {
$connection = $this->fakeConnectionForApp($appId, $socketId);
$this->unsubscribeFromAllChannels($connection);
$promises[] = $this->unsubscribeFromAllChannels($connection);
}
});
});
return all($promises);
});
})->then(function () {
return parent::removeObsoleteConnections();
} finally {
optional($lock)->forceRelease();
}
});
}
/**
@ -846,11 +867,11 @@ class RedisChannelManager extends LocalChannelManager
/**
* Get a new RedisLock instance to avoid race conditions.
*
* @return \Illuminate\Cache\CacheLock
* @return RedisLock
*/
protected function lock()
{
return new RedisLock($this->redis, static::$lockName, 0);
return new RedisLock($this->publishClient, static::$lockName, 0);
}
/**

View File

@ -6,9 +6,11 @@ use BeyondCode\LaravelWebSockets\Contracts\ChannelManager;
use BeyondCode\LaravelWebSockets\DashboardLogger;
use BeyondCode\LaravelWebSockets\Events\SubscribedToChannel;
use BeyondCode\LaravelWebSockets\Events\UnsubscribedFromChannel;
use BeyondCode\LaravelWebSockets\Helpers;
use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature;
use Illuminate\Support\Str;
use Ratchet\ConnectionInterface;
use React\Promise\PromiseInterface;
use stdClass;
class Channel
@ -116,12 +118,12 @@ class Channel
* Unsubscribe connection from the channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @return bool
* @return PromiseInterface
*/
public function unsubscribe(ConnectionInterface $connection): bool
public function unsubscribe(ConnectionInterface $connection): PromiseInterface
{
if (! $this->hasConnection($connection)) {
return false;
return Helpers::createFulfilledPromise(false);
}
unset($this->connections[$connection->socketId]);
@ -132,7 +134,7 @@ class Channel
$this->getName()
);
return true;
return Helpers::createFulfilledPromise(true);
}
/**

View File

@ -5,8 +5,10 @@ namespace BeyondCode\LaravelWebSockets\Channels;
use BeyondCode\LaravelWebSockets\DashboardLogger;
use BeyondCode\LaravelWebSockets\Events\SubscribedToChannel;
use BeyondCode\LaravelWebSockets\Events\UnsubscribedFromChannel;
use BeyondCode\LaravelWebSockets\Helpers;
use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature;
use Ratchet\ConnectionInterface;
use React\Promise\PromiseInterface;
use stdClass;
class PresenceChannel extends PrivateChannel
@ -100,30 +102,30 @@ class PresenceChannel extends PrivateChannel
* Unsubscribe connection from the channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @return bool
* @return PromiseInterface
*/
public function unsubscribe(ConnectionInterface $connection): bool
public function unsubscribe(ConnectionInterface $connection): PromiseInterface
{
$truth = parent::unsubscribe($connection);
$this->channelManager
return $this->channelManager
->getChannelMember($connection, $this->getName())
->then(function ($user) {
return @json_decode($user);
})
->then(function ($user) use ($connection) {
if (! $user) {
return;
return Helpers::createFulfilledPromise(true);
}
$this->channelManager
return $this->channelManager
->userLeftPresenceChannel($connection, $user, $this->getName())
->then(function () use ($connection, $user) {
// The `pusher_internal:member_removed` is triggered when a user leaves a channel.
// It's quite possible that a user can have multiple connections to the same channel
// (for example by having multiple browser tabs open)
// and in this case the events will only be triggered when the last one is closed.
$this->channelManager
return $this->channelManager
->getMemberSockets($user->user_id, $connection->app->id, $this->getName())
->then(function ($sockets) use ($connection, $user) {
if (count($sockets) === 0) {
@ -149,8 +151,9 @@ class PresenceChannel extends PrivateChannel
}
});
});
});
})
->then(function () use ($truth) {
return $truth;
});
}
}

View File

@ -12,6 +12,8 @@ use BeyondCode\LaravelWebSockets\ServerFactory;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use React\EventLoop\Factory as LoopFactory;
use React\EventLoop\LoopInterface;
use function React\Promise\all;
class StartServer extends Command
{
@ -69,6 +71,10 @@ class StartServer extends Command
*/
public function handle()
{
$this->laravel->singleton(LoopInterface::class, function () {
return $this->loop;
});
$this->configureLoggers();
$this->configureManagers();
@ -311,9 +317,13 @@ class StartServer extends Command
// be automatically be unsubscribed from all channels.
$channelManager->getLocalConnections()
->then(function ($connections) {
foreach ($connections as $connection) {
return all(collect($connections)->map(function ($connection) {
return app('websockets.handler')
->onClose($connection)
->then(function () use ($connection) {
$connection->close();
}
});
})->toArray());
})
->then(function () {
$this->loop->stop();

View File

@ -3,37 +3,46 @@
namespace BeyondCode\LaravelWebSockets\Contracts;
use BeyondCode\LaravelWebSockets\Apps\App;
use React\Promise\PromiseInterface;
interface AppManager
{
/**
* Get all apps.
*
* @return array[\BeyondCode\LaravelWebSockets\Apps\App]
* @return PromiseInterface
*/
public function all(): array;
public function all(): PromiseInterface;
/**
* Get app by id.
*
* @param string|int $appId
* @return \BeyondCode\LaravelWebSockets\Apps\App|null
* @return PromiseInterface
*/
public function findById($appId): ?App;
public function findById($appId): PromiseInterface;
/**
* Get app by app key.
*
* @param string $appKey
* @return \BeyondCode\LaravelWebSockets\Apps\App|null
* @return PromiseInterface
*/
public function findByKey($appKey): ?App;
public function findByKey($appKey): PromiseInterface;
/**
* Get app by secret.
*
* @param string $appSecret
* @return \BeyondCode\LaravelWebSockets\Apps\App|null
* @return PromiseInterface
*/
public function findBySecret($appSecret): ?App;
public function findBySecret($appSecret): PromiseInterface;
/**
* Create a new app.
*
* @param $appData
* @return PromiseInterface
*/
public function createApp($appData): PromiseInterface;
}

View File

@ -4,8 +4,10 @@ namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers;
use BeyondCode\LaravelWebSockets\Apps\App;
use BeyondCode\LaravelWebSockets\Concerns\PushesToPusher;
use function Clue\React\Block\await;
use Illuminate\Broadcasting\Broadcasters\PusherBroadcaster;
use Illuminate\Http\Request;
use React\EventLoop\LoopInterface;
class AuthenticateDashboard
{
@ -21,7 +23,7 @@ class AuthenticateDashboard
*/
public function __invoke(Request $request)
{
$app = App::findById($request->header('X-App-Id'));
$app = await(App::findById($request->header('X-App-Id')), app(LoopInterface::class));
$broadcaster = $this->getPusherBroadcaster([
'key' => $app->key,

View File

@ -0,0 +1,26 @@
<?php
namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers;
use BeyondCode\LaravelWebSockets\Contracts\AppManager;
use function Clue\React\Block\await;
use Illuminate\Http\Request;
use React\EventLoop\LoopInterface;
class ShowApps
{
/**
* Show the configured apps.
*
* @param \Illuminate\Http\Request $request
* @param \BeyondCode\LaravelWebSockets\Contracts\AppManager $apps
* @return void
*/
public function __invoke(Request $request, AppManager $apps)
{
return view('websockets::apps', [
'apps' => await($apps->all(), app(LoopInterface::class), 2.0),
'port' => config('websockets.dashboard.port', 6001),
]);
}
}

View File

@ -4,7 +4,9 @@ namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers;
use BeyondCode\LaravelWebSockets\Contracts\AppManager;
use BeyondCode\LaravelWebSockets\DashboardLogger;
use function Clue\React\Block\await;
use Illuminate\Http\Request;
use React\EventLoop\LoopInterface;
class ShowDashboard
{
@ -18,7 +20,7 @@ class ShowDashboard
public function __invoke(Request $request, AppManager $apps)
{
return view('websockets::dashboard', [
'apps' => $apps->all(),
'apps' => await($apps->all(), app(LoopInterface::class), 2.0),
'port' => config('websockets.dashboard.port', 6001),
'channels' => DashboardLogger::$channels,
'logPrefix' => DashboardLogger::LOG_CHANNEL_PREFIX,

View File

@ -0,0 +1,36 @@
<?php
namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers;
use BeyondCode\LaravelWebSockets\Contracts\AppManager;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Requests\StoreAppRequest;
use function Clue\React\Block\await;
use Illuminate\Support\Str;
use React\EventLoop\LoopInterface;
class StoreApp
{
/**
* Show the configured apps.
*
* @param StoreAppRequest $request
* @param \BeyondCode\LaravelWebSockets\Contracts\AppManager $apps
* @return void
*/
public function __invoke(StoreAppRequest $request, AppManager $apps)
{
$appData = [
'id' => (string) Str::uuid(),
'key' => (string) Str::uuid(),
'secret' => (string) Str::uuid(),
'name' => $request->get('name'),
'enable_client_messages' => $request->has('enable_client_messages'),
'enable_statistics' => $request->has('enable_statistics'),
'allowed_origins' => $request->get('allowed_origins'),
];
await($apps->createApp($appData), app(LoopInterface::class));
return redirect()->route('laravel-websockets.apps');
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace BeyondCode\LaravelWebSockets\Dashboard\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreAppRequest extends FormRequest
{
public function authenticate()
{
return true;
}
public function rules()
{
return [
'name' => 'required',
];
}
}

View File

@ -3,7 +3,9 @@
namespace BeyondCode\LaravelWebSockets\Rules;
use BeyondCode\LaravelWebSockets\Contracts\AppManager;
use function Clue\React\Block\await;
use Illuminate\Contracts\Validation\Rule;
use React\EventLoop\Factory;
class AppId implements Rule
{
@ -18,7 +20,7 @@ class AppId implements Rule
{
$manager = app(AppManager::class);
return $manager->findById($value) ? true : false;
return await($manager->findById($value), Factory::create()) ? true : false;
}
/**

View File

@ -70,7 +70,7 @@ class Router
*/
public function registerRoutes()
{
$this->get('/app/{appKey}', config('websockets.handlers.websocket'));
$this->get('/app/{appKey}', 'websockets.handler');
$this->post('/apps/{appId}/events', config('websockets.handlers.trigger_event'));
$this->get('/apps/{appId}/channels', config('websockets.handlers.fetch_channels'));
$this->get('/apps/{appId}/channels/{channelName}', config('websockets.handlers.fetch_channel'));
@ -191,9 +191,10 @@ class Router
*/
protected function getRoute(string $method, string $uri, $action): Route
{
$action = app($action);
$action = is_subclass_of($action, MessageComponentInterface::class)
? $this->createWebSocketsServer($action)
: app($action);
: $action;
return new Route($uri, ['_controller' => $action], [], [], null, [], [$method]);
}
@ -201,13 +202,11 @@ class Router
/**
* Create a new websockets server to handle the action.
*
* @param string $action
* @param MessageComponentInterface $app
* @return \Ratchet\WebSocket\WsServer
*/
protected function createWebSocketsServer(string $action): WsServer
protected function createWebSocketsServer($app): WsServer
{
$app = app($action);
if (WebsocketsLogger::isEnabled()) {
$app = WebsocketsLogger::decorate($app);
}

View File

@ -9,10 +9,14 @@ use BeyondCode\LaravelWebSockets\Events\ConnectionClosed;
use BeyondCode\LaravelWebSockets\Events\NewConnection;
use BeyondCode\LaravelWebSockets\Events\WebSocketMessageReceived;
use BeyondCode\LaravelWebSockets\Facades\StatisticsCollector;
use BeyondCode\LaravelWebSockets\Helpers;
use BeyondCode\LaravelWebSockets\Server\Exceptions\WebSocketException;
use Exception;
use Ratchet\ConnectionInterface;
use Ratchet\RFC6455\Messaging\MessageInterface;
use Ratchet\WebSocket\MessageComponentInterface;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
class WebSocketHandler implements MessageComponentInterface
{
@ -47,7 +51,9 @@ class WebSocketHandler implements MessageComponentInterface
}
$this->verifyAppKey($connection)
->verifyOrigin($connection)
->then(function () use ($connection) {
try {
$this->verifyOrigin($connection)
->limitConcurrentConnections($connection)
->generateSocketId($connection)
->establishConnection($connection);
@ -71,6 +77,12 @@ class WebSocketHandler implements MessageComponentInterface
NewConnection::dispatch($connection->app->id, $connection->socketId);
}
} catch (WebSocketException $exception) {
$this->onError($connection, $exception);
}
}, function ($exception) use ($connection) {
$this->onError($connection, $exception);
});
}
/**
@ -105,11 +117,11 @@ class WebSocketHandler implements MessageComponentInterface
* Handle the websocket close.
*
* @param \Ratchet\ConnectionInterface $connection
* @return void
* @return PromiseInterface
*/
public function onClose(ConnectionInterface $connection)
{
$this->channelManager
return $this->channelManager
->unsubscribeFromAllChannels($connection)
->then(function (bool $unsubscribed) use ($connection) {
if (isset($connection->app)) {
@ -117,8 +129,13 @@ class WebSocketHandler implements MessageComponentInterface
StatisticsCollector::disconnection($connection->app->id);
}
$this->channelManager->unsubscribeFromApp($connection->app->id);
return $this->channelManager->unsubscribeFromApp($connection->app->id);
}
return Helpers::createFulfilledPromise(true);
})
->then(function () use ($connection) {
if (isset($connection->app)) {
DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_DISCONNECTED, [
'socketId' => $connection->socketId,
]);
@ -160,21 +177,28 @@ class WebSocketHandler implements MessageComponentInterface
* Verify the app key validity.
*
* @param \Ratchet\ConnectionInterface $connection
* @return $this
* @return PromiseInterface
*/
protected function verifyAppKey(ConnectionInterface $connection)
protected function verifyAppKey(ConnectionInterface $connection): PromiseInterface
{
$deferred = new Deferred();
$query = QueryParameters::create($connection->httpRequest);
$appKey = $query->get('appKey');
if (! $app = App::findByKey($appKey)) {
throw new Exceptions\UnknownAppKey($appKey);
App::findByKey($appKey)
->then(function ($app) use ($appKey, $connection, $deferred) {
if (! $app) {
$deferred->reject(new Exceptions\UnknownAppKey($appKey));
}
$connection->app = $app;
return $this;
$deferred->resolve();
});
return $deferred->promise();
}
/**

View File

@ -92,14 +92,15 @@ class MemoryCollector implements StatisticsCollector
{
$this->getStatistics()->then(function ($statistics) {
foreach ($statistics as $appId => $statistic) {
if (! $statistic->isEnabled()) {
continue;
$statistic->isEnabled()->then(function ($isEnabled) use ($appId, $statistic) {
if (! $isEnabled) {
return;
}
if ($statistic->shouldHaveTracesRemoved()) {
$this->resetAppTraces($appId);
continue;
return;
}
$this->createRecord($statistic, $appId);
@ -111,6 +112,7 @@ class MemoryCollector implements StatisticsCollector
is_null($connections) ? 0 : $connections
);
});
});
}
});
}

View File

@ -3,6 +3,8 @@
namespace BeyondCode\LaravelWebSockets\Statistics;
use BeyondCode\LaravelWebSockets\Apps\App;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
class Statistic
{
@ -118,11 +120,17 @@ class Statistic
/**
* Check if the app has statistics enabled.
*
* @return bool
* @return PromiseInterface
*/
public function isEnabled(): bool
public function isEnabled(): PromiseInterface
{
return App::findById($this->appId)->statisticsEnabled;
$deferred = new Deferred();
App::findById($this->appId)->then(function ($app) use ($deferred) {
$deferred->resolve($app->statisticsEnabled);
});
return $deferred->promise();
}
/**

View File

@ -6,15 +6,25 @@ use BeyondCode\LaravelWebSockets\Contracts\StatisticsCollector;
use BeyondCode\LaravelWebSockets\Contracts\StatisticsStore;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\AuthenticateDashboard;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\SendMessage;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\ShowApps;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\ShowDashboard;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\ShowStatistics;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Controllers\StoreApp;
use BeyondCode\LaravelWebSockets\Dashboard\Http\Middleware\Authorize as AuthorizeDashboard;
use BeyondCode\LaravelWebSockets\Queue\AsyncRedisConnector;
use BeyondCode\LaravelWebSockets\Server\Router;
use Clue\React\SQLite\DatabaseInterface;
use Clue\React\SQLite\Factory as SQLiteFactory;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use React\EventLoop\Factory;
use React\EventLoop\LoopInterface;
use React\MySQL\ConnectionInterface;
use React\MySQL\Factory as MySQLFactory;
use SplFileInfo;
use Symfony\Component\Finder\Finder;
class WebSocketsServiceProvider extends ServiceProvider
{
@ -38,8 +48,16 @@ class WebSocketsServiceProvider extends ServiceProvider
__DIR__.'/../database/migrations/0000_00_00_000000_rename_statistics_counters.php' => database_path('migrations/0000_00_00_000000_rename_statistics_counters.php'),
], 'migrations');
$this->registerEventLoop();
$this->registerSQLiteDatabase();
$this->registerMySqlDatabase();
$this->registerAsyncRedisQueueDriver();
$this->registerWebSocketHandler();
$this->registerRouter();
$this->registerManagers();
@ -61,6 +79,13 @@ class WebSocketsServiceProvider extends ServiceProvider
//
}
protected function registerEventLoop()
{
$this->app->singleton(LoopInterface::class, function () {
return Factory::create();
});
}
/**
* Register the async, non-blocking Redis queue driver.
*
@ -73,6 +98,47 @@ class WebSocketsServiceProvider extends ServiceProvider
});
}
protected function registerSQLiteDatabase()
{
$this->app->singleton(DatabaseInterface::class, function () {
$factory = new SQLiteFactory($this->app->make(LoopInterface::class));
$database = $factory->openLazy(
config('websockets.managers.sqlite.database', ':memory:')
);
$migrations = (new Finder())
->files()
->ignoreDotFiles(true)
->in(__DIR__.'/../database/migrations/sqlite')
->name('*.sql');
/** @var SplFileInfo $migration */
foreach ($migrations as $migration) {
$database->exec($migration->getContents());
}
return $database;
});
}
protected function registerMySqlDatabase()
{
$this->app->singleton(ConnectionInterface::class, function () {
$factory = new MySQLFactory($this->app->make(LoopInterface::class));
$connectionKey = 'database.connections.'.config('websockets.managers.mysql.connection');
$auth = trim(config($connectionKey.'.username').':'.config($connectionKey.'.password'), ':');
$connection = trim(config($connectionKey.'.host').':'.config($connectionKey.'.port'), ':');
$database = config($connectionKey.'.database');
$database = $factory->createLazyConnection(trim("{$auth}@{$connection}/{$database}", '@'));
return $database;
});
}
/**
* Register the statistics-related contracts.
*
@ -98,7 +164,7 @@ class WebSocketsServiceProvider extends ServiceProvider
}
/**
* Regsiter the dashboard components.
* Register the dashboard components.
*
* @return void
*/
@ -165,6 +231,8 @@ class WebSocketsServiceProvider extends ServiceProvider
'middleware' => config('websockets.dashboard.middleware', [AuthorizeDashboard::class]),
], function () {
Route::get('/', ShowDashboard::class)->name('dashboard');
Route::get('/apps', ShowApps::class)->name('apps');
Route::post('/apps', StoreApp::class)->name('apps.store');
Route::get('/api/{appId}/statistics', ShowStatistics::class)->name('statistics');
Route::post('/auth', AuthenticateDashboard::class)->name('auth');
Route::post('/event', SendMessage::class)->name('event');
@ -182,4 +250,11 @@ class WebSocketsServiceProvider extends ServiceProvider
return $this->app->environment('local');
});
}
protected function registerWebSocketHandler()
{
$this->app->singleton('websockets.handler', function () {
return app(config('websockets.handlers.websocket'));
});
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace BeyondCode\LaravelWebSockets\Test\Apps;
use BeyondCode\LaravelWebSockets\Apps\App;
use BeyondCode\LaravelWebSockets\Apps\ConfigAppManager;
use BeyondCode\LaravelWebSockets\Contracts\AppManager;
use BeyondCode\LaravelWebSockets\Test\TestCase;
class ConfigAppManagerTest extends TestCase
{
/** @var AppManager */
protected $apps;
public function getEnvironmentSetUp($app)
{
parent::getEnvironmentSetUp($app);
$app['config']->set('websockets.managers.app', ConfigAppManager::class);
$app['config']->set('websockets.apps', []);
}
public function setUp(): void
{
parent::setUp();
$this->apps = app()->make(AppManager::class);
}
public function test_can_return_all_apps()
{
$apps = $this->await($this->apps->all());
$this->assertCount(0, $apps);
$this->await($this->apps->createApp([
'id' => 1,
'key' => 'test',
'secret' => 'secret',
'name' => 'Test',
'enable_client_messages' => true,
'enable_statistics' => false,
]));
$apps = $this->await($this->apps->all());
$this->assertCount(1, $apps);
}
public function test_can_find_apps_by_id()
{
$this->await($this->apps->createApp([
'id' => 1,
'key' => 'test',
'secret' => 'secret',
'name' => 'Test',
'enable_client_messages' => true,
'enable_statistics' => false,
]));
$app = $this->await($this->apps->findById(1));
$this->assertInstanceOf(App::class, $app);
$this->assertSame('test', $app->key);
}
public function test_can_find_apps_by_key()
{
$this->await($this->apps->createApp([
'id' => 1,
'key' => 'key',
'secret' => 'secret',
'name' => 'Test',
'enable_client_messages' => true,
'enable_statistics' => false,
]));
$app = $this->await($this->apps->findByKey('key'));
$this->assertInstanceOf(App::class, $app);
$this->assertSame('key', $app->key);
}
public function test_can_find_apps_by_secret()
{
$this->await($this->apps->createApp([
'id' => 1,
'key' => 'key',
'secret' => 'secret',
'name' => 'Test',
'enable_client_messages' => true,
'enable_statistics' => false,
]));
$app = $this->await($this->apps->findBySecret('secret'));
$this->assertInstanceOf(App::class, $app);
$this->assertSame('key', $app->key);
}
}

View File

@ -0,0 +1,110 @@
<?php
namespace BeyondCode\LaravelWebSockets\Test\Apps;
use BeyondCode\LaravelWebSockets\Apps\App;
use BeyondCode\LaravelWebSockets\Apps\MysqlAppManager;
use BeyondCode\LaravelWebSockets\Contracts\AppManager;
use BeyondCode\LaravelWebSockets\Test\TestCase;
class MysqlAppManagerTest extends TestCase
{
/** @var AppManager */
protected $apps;
public function getEnvironmentSetUp($app)
{
parent::getEnvironmentSetUp($app);
$app['config']->set('websockets.managers.app', MysqlAppManager::class);
$app['config']->set('database.connections.mysql.database', 'websockets_test');
$app['config']->set('database.connections.mysql.username', 'root');
$app['config']->set('database.connections.mysql.password', 'password');
$app['config']->set('websockets.managers.mysql.table', 'websockets_apps');
$app['config']->set('websockets.managers.mysql.connection', 'mysql');
$app['config']->set('database.connections.default', 'mysql');
}
public function setUp(): void
{
parent::setUp();
$this->artisan('migrate:fresh', [
'--database' => 'mysql',
'--realpath' => true,
'--path' => __DIR__.'/../../database/migrations/',
]);
$this->apps = app()->make(AppManager::class);
}
public function test_can_return_all_apps()
{
$apps = $this->await($this->apps->all());
$this->assertCount(0, $apps);
$this->await($this->apps->createApp([
'id' => 1,
'key' => 'test',
'secret' => 'secret',
'name' => 'Test',
'enable_client_messages' => true,
'enable_statistics' => false,
]));
$apps = $this->await($this->apps->all());
$this->assertCount(1, $apps);
}
public function test_can_find_apps_by_id()
{
$this->await($this->apps->createApp([
'id' => 1,
'key' => 'test',
'secret' => 'secret',
'name' => 'Test',
'enable_client_messages' => true,
'enable_statistics' => false,
]));
$app = $this->await($this->apps->findById(1));
$this->assertInstanceOf(App::class, $app);
$this->assertSame('test', $app->key);
}
public function test_can_find_apps_by_key()
{
$this->await($this->apps->createApp([
'id' => 1,
'key' => 'key',
'secret' => 'secret',
'name' => 'Test',
'enable_client_messages' => true,
'enable_statistics' => false,
]));
$app = $this->await($this->apps->findByKey('key'));
$this->assertInstanceOf(App::class, $app);
$this->assertSame('key', $app->key);
}
public function test_can_find_apps_by_secret()
{
$this->await($this->apps->createApp([
'id' => 1,
'key' => 'key',
'secret' => 'secret',
'name' => 'Test',
'enable_client_messages' => true,
'enable_statistics' => false,
]));
$app = $this->await($this->apps->findBySecret('secret'));
$this->assertInstanceOf(App::class, $app);
$this->assertSame('key', $app->key);
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace BeyondCode\LaravelWebSockets\Test\Apps;
use BeyondCode\LaravelWebSockets\Apps\App;
use BeyondCode\LaravelWebSockets\Apps\SQLiteAppManager;
use BeyondCode\LaravelWebSockets\Contracts\AppManager;
use BeyondCode\LaravelWebSockets\Test\TestCase;
class SqliteAppManagerTest extends TestCase
{
/** @var AppManager */
protected $apps;
public function getEnvironmentSetUp($app)
{
parent::getEnvironmentSetUp($app);
$app['config']->set('websockets.managers.app', SQLiteAppManager::class);
}
public function setUp(): void
{
parent::setUp();
$this->apps = app()->make(AppManager::class);
}
public function test_can_return_all_apps()
{
$apps = $this->await($this->apps->all());
$this->assertCount(0, $apps);
$this->await($this->apps->createApp([
'id' => 1,
'key' => 'test',
'secret' => 'secret',
'name' => 'Test',
'enable_client_messages' => true,
'enable_statistics' => false,
]));
$apps = $this->await($this->apps->all());
$this->assertCount(1, $apps);
}
public function test_can_find_apps_by_id()
{
$this->await($this->apps->createApp([
'id' => 1,
'key' => 'test',
'secret' => 'secret',
'name' => 'Test',
'enable_client_messages' => true,
'enable_statistics' => false,
]));
$app = $this->await($this->apps->findById(1));
$this->assertInstanceOf(App::class, $app);
$this->assertSame('test', $app->key);
}
public function test_can_find_apps_by_key()
{
$this->await($this->apps->createApp([
'id' => 1,
'key' => 'key',
'secret' => 'secret',
'name' => 'Test',
'enable_client_messages' => true,
'enable_statistics' => false,
]));
$app = $this->await($this->apps->findByKey('key'));
$this->assertInstanceOf(App::class, $app);
$this->assertSame('key', $app->key);
}
public function test_can_find_apps_by_secret()
{
$this->await($this->apps->createApp([
'id' => 1,
'key' => 'key',
'secret' => 'secret',
'name' => 'Test',
'enable_client_messages' => true,
'enable_statistics' => false,
]));
$app = $this->await($this->apps->findBySecret('secret'));
$this->assertInstanceOf(App::class, $app);
$this->assertSame('key', $app->key);
}
}

View File

@ -2,43 +2,43 @@
namespace BeyondCode\LaravelWebSockets\Test;
use BeyondCode\LaravelWebSockets\Server\Exceptions\OriginNotAllowed;
use BeyondCode\LaravelWebSockets\Server\Exceptions\UnknownAppKey;
class ConnectionTest extends TestCase
{
public function test_cannot_connect_with_a_wrong_app_key()
{
$this->expectException(UnknownAppKey::class);
$this->startServer();
$this->newActiveConnection(['public-channel'], 'NonWorkingKey');
$response = $this->await($this->joinWebSocketServer(['public-channel'], 'NonWorkingKey'));
$this->assertSame('{"event":"pusher:error","data":{"message":"Could not find app key `NonWorkingKey`.","code":4001}}', (string) $response);
}
public function test_unconnected_app_cannot_store_statistics()
{
$this->expectException(UnknownAppKey::class);
$this->startServer();
$this->newActiveConnection(['public-channel'], 'NonWorkingKey');
$response = $this->await($this->joinWebSocketServer(['public-channel'], 'NonWorkingKey'));
$this->assertSame('{"event":"pusher:error","data":{"message":"Could not find app key `NonWorkingKey`.","code":4001}}', (string) $response);
$this->assertCount(0, $this->statisticsCollector->getStatistics());
$count = $this->await($this->statisticsCollector->getStatistics());
$this->assertCount(0, $count);
}
public function test_origin_validation_should_fail_for_no_origin()
{
$this->expectException(OriginNotAllowed::class);
$this->startServer();
$connection = $this->newConnection('TestOrigin');
$this->pusherServer->onOpen($connection);
$response = $this->await($this->joinWebSocketServer(['public-channel'], 'TestOrigin'));
$this->assertSame('{"event":"pusher:error","data":{"message":"The origin is not allowed for `TestOrigin`.","code":4009}}', (string) $response);
}
public function test_origin_validation_should_fail_for_wrong_origin()
{
$this->expectException(OriginNotAllowed::class);
$this->startServer();
$connection = $this->newConnection('TestOrigin', ['Origin' => 'https://google.ro']);
$this->pusherServer->onOpen($connection);
$response = $this->await($this->joinWebSocketServer(['public-channel'], 'TestOrigin', ['Origin' => 'https://google.ro']));
$this->assertSame('{"event":"pusher:error","data":{"message":"The origin is not allowed for `TestOrigin`.","code":4009}}', (string) $response);
}
public function test_origin_validation_should_pass_for_the_right_origin()

View File

@ -0,0 +1,38 @@
<?php
namespace BeyondCode\LaravelWebSockets\Test\Dashboard;
use BeyondCode\LaravelWebSockets\Apps\SQLiteAppManager;
use BeyondCode\LaravelWebSockets\Test\Models\User;
use BeyondCode\LaravelWebSockets\Test\TestCase;
class AppsTest extends TestCase
{
public function getEnvironmentSetUp($app)
{
parent::getEnvironmentSetUp($app);
$app['config']->set('websockets.managers.app', SQLiteAppManager::class);
}
public function test_can_list_all_apps()
{
$this->actingAs(factory(User::class)->create())
->get(route('laravel-websockets.apps'))
->assertViewHas('apps', []);
}
public function test_can_create_app()
{
$this->actingAs(factory(User::class)->create())
->post(route('laravel-websockets.apps.store', [
'name' => 'New App',
]));
$this->actingAs(factory(User::class)->create())
->get(route('laravel-websockets.apps'))
->assertViewHas('apps', function ($apps) {
return count($apps) === 1 && $apps[0]['name'] === 'New App';
});
}
}

View File

@ -17,7 +17,6 @@ class DashboardTest extends TestCase
{
$this->actingAs(factory(User::class)->create())
->get(route('laravel-websockets.dashboard'))
->assertResponseOk()
->see('WebSockets Dashboard');
->assertResponseOk();
}
}

View File

@ -5,33 +5,26 @@ namespace BeyondCode\LaravelWebSockets\Test;
use BeyondCode\LaravelWebSockets\API\FetchChannel;
use GuzzleHttp\Psr7\Request;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Pusher\Pusher;
class FetchChannelTest extends TestCase
{
public function test_invalid_signatures_can_not_access_the_api()
{
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Invalid auth signature provided.');
$this->startServer();
$connection = new Mocks\Connection;
$requestPath = '/apps/1234/channels/my-channel';
$requestPath = '/apps/1234/channel/my-channel';
$routeParams = [
'appId' => '1234',
'channelName' => 'my-channel',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'InvalidSecret', 'GET', $requestPath
);
));
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
$request = new Request('GET', "{$requestPath}?{$queryString}");
$controller = app(FetchChannel::class);
$response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}"));
$controller->onOpen($connection, $request);
$this->assertSame(401, $response->getStatusCode());
$this->assertSame('{"error":"Invalid auth signature provided."}', $response->getBody()->getContents());
}
public function test_it_returns_the_channel_information()
@ -47,7 +40,7 @@ class FetchChannelTest extends TestCase
'channelName' => 'my-channel',
];
$queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath);
$queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath));
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
@ -78,7 +71,7 @@ class FetchChannelTest extends TestCase
'channelName' => 'presence-channel',
];
$queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath);
$queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath));
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
@ -100,26 +93,17 @@ class FetchChannelTest extends TestCase
{
$this->skipOnRedisReplication();
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Unknown channel');
$this->startServer();
$this->newActiveConnection(['my-channel']);
$connection = new Mocks\Connection;
$requestPath = '/apps/1234/channels/invalid-channel';
$requestPath = '/apps/1234/channel/invalid-channel';
$queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath));
$routeParams = [
'appId' => '1234',
'channelName' => 'invalid-channel',
];
$response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}"));
$queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath);
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
$controller = app(FetchChannel::class);
$controller->onOpen($connection, $request);
$this->assertSame(404, $response->getStatusCode());
$this->assertSame('{"error":"Unknown channel `invalid-channel`."}', $response->getBody()->getContents());
}
}

View File

@ -5,32 +5,26 @@ namespace BeyondCode\LaravelWebSockets\Test;
use BeyondCode\LaravelWebSockets\API\FetchChannels;
use GuzzleHttp\Psr7\Request;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Pusher\Pusher;
class FetchChannelsTest extends TestCase
{
public function test_invalid_signatures_can_not_access_the_api()
{
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Invalid auth signature provided.');
$connection = new Mocks\Connection;
$this->startServer();
$requestPath = '/apps/1234/channels';
$routeParams = [
'appId' => '1234',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'InvalidSecret', 'GET', $requestPath
);
));
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
$request = new Request('GET', "{$requestPath}?{$queryString}");
$controller = app(FetchChannels::class);
$response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}"));
$controller->onOpen($connection, $request);
$this->assertSame(401, $response->getStatusCode());
$this->assertSame('{"error":"Invalid auth signature provided."}', $response->getBody()->getContents());
}
public function test_it_returns_the_channel_information()
@ -45,9 +39,9 @@ class FetchChannelsTest extends TestCase
'appId' => '1234',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'TestSecret', 'GET', $requestPath
);
));
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
@ -80,9 +74,9 @@ class FetchChannelsTest extends TestCase
'appId' => '1234',
];
$queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath, [
$queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath, [
'filter_by_prefix' => 'presence-global',
]);
]));
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
@ -116,10 +110,10 @@ class FetchChannelsTest extends TestCase
'appId' => '1234',
];
$queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath, [
$queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath, [
'filter_by_prefix' => 'presence-global',
'info' => 'user_count',
]);
]));
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
@ -144,29 +138,18 @@ class FetchChannelsTest extends TestCase
public function test_can_not_get_non_presence_channel_user_count()
{
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Request must be limited to presence channels in order to fetch user_count');
$connection = new Mocks\Connection;
$this->startServer();
$requestPath = '/apps/1234/channels';
$routeParams = [
'appId' => '1234',
];
$queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath, [
$queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath, [
'info' => 'user_count',
]);
]));
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
$response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}"));
$controller = app(FetchChannels::class);
$controller->onOpen($connection, $request);
/** @var JsonResponse $response */
$response = array_pop($connection->sentRawData);
$this->assertSame(400, $response->getStatusCode());
$this->assertSame('{"error":"Request must be limited to presence channels in order to fetch user_count"}', $response->getBody()->getContents());
}
public function test_it_returns_empty_object_for_no_channels_found()
@ -179,7 +162,7 @@ class FetchChannelsTest extends TestCase
'appId' => '1234',
];
$queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath);
$queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath));
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));

View File

@ -4,87 +4,56 @@ namespace BeyondCode\LaravelWebSockets\Test;
use BeyondCode\LaravelWebSockets\API\FetchUsers;
use GuzzleHttp\Psr7\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Pusher\Pusher;
class FetchUsersTest extends TestCase
{
public function test_invalid_signatures_can_not_access_the_api()
{
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Invalid auth signature provided.');
$this->startServer();
$connection = new Mocks\Connection;
$requestPath = '/apps/1234/channels/my-channel/users';
$requestPath = '/apps/1234/channel/my-channel';
$routeParams = [
'appId' => '1234',
'channelName' => 'my-channel',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'InvalidSecret', 'GET', $requestPath
);
));
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
$response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}"));
$controller = app(FetchUsers::class);
$controller->onOpen($connection, $request);
$this->assertSame(401, $response->getStatusCode());
$this->assertSame('{"error":"Invalid auth signature provided."}', $response->getBody()->getContents());
}
public function test_it_only_returns_data_for_presence_channels()
{
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Invalid presence channel');
$this->startServer();
$this->newActiveConnection(['my-channel']);
$requestPath = '/apps/1234/channels/my-channel/users';
$connection = new Mocks\Connection;
$requestPath = '/apps/1234/channel/my-channel/users';
$routeParams = [
'appId' => '1234',
'channelName' => 'my-channel',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'TestSecret', 'GET', $requestPath
);
));
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
$response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}"));
$controller = app(FetchUsers::class);
$controller->onOpen($connection, $request);
$this->assertSame(400, $response->getStatusCode());
$this->assertSame('{"error":"Invalid presence channel `my-channel`"}', $response->getBody()->getContents());
}
public function test_it_returns_404_for_invalid_channels()
public function test_it_returns_400_for_invalid_channels()
{
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Invalid presence channel');
$this->startServer();
$this->newActiveConnection(['my-channel']);
$requestPath = '/apps/1234/channels/invalid-channel/users';
$connection = new Mocks\Connection;
$requestPath = '/apps/1234/channel/invalid-channel/users';
$routeParams = [
'appId' => '1234',
'channelName' => 'invalid-channel',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'TestSecret', 'GET', $requestPath
);
));
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
$response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}"));
$controller = app(FetchUsers::class);
$controller->onOpen($connection, $request);
$this->assertSame(400, $response->getStatusCode());
$this->assertSame('{"error":"Invalid presence channel `invalid-channel`"}', $response->getBody()->getContents());
}
public function test_it_returns_connected_user_information()
@ -100,7 +69,7 @@ class FetchUsersTest extends TestCase
'channelName' => 'presence-channel',
];
$queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath);
$queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath));
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
@ -130,7 +99,7 @@ class FetchUsersTest extends TestCase
'channelName' => 'presence-channel',
];
$queryString = self::build_auth_query_string('TestKey', 'TestSecret', 'GET', $requestPath);
$queryString = http_build_query(Pusher::build_auth_query_params('TestKey', 'TestSecret', 'GET', $requestPath));
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));

View File

@ -6,6 +6,7 @@ use BeyondCode\LaravelWebSockets\API\TriggerEvent;
use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature;
use GuzzleHttp\Psr7\Request;
use Illuminate\Http\JsonResponse;
use Pusher\Pusher;
use Ratchet\ConnectionInterface;
class PresenceChannelTest extends TestCase
@ -418,13 +419,13 @@ class PresenceChannelTest extends TestCase
'appId' => '1234',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'TestSecret', 'POST', $requestPath, [
'name' => 'some-event',
'channels' => ['presence-channel'],
'data' => json_encode(['some-data' => 'yes']),
],
);
));
$request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
@ -459,13 +460,13 @@ class PresenceChannelTest extends TestCase
'appId' => '1234',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'TestSecret', 'POST', $requestPath, [
'name' => 'some-event',
'channels' => ['presence-channel'],
'data' => json_encode(['some-data' => 'yes']),
],
);
));
$request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
@ -507,13 +508,13 @@ class PresenceChannelTest extends TestCase
'appId' => '1234',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'TestSecret', 'POST', $requestPath, [
'name' => 'some-event',
'channels' => ['presence-channel'],
'data' => json_encode(['some-data' => 'yes']),
],
);
));
$request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams));

View File

@ -6,6 +6,7 @@ use BeyondCode\LaravelWebSockets\API\TriggerEvent;
use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature;
use GuzzleHttp\Psr7\Request;
use Illuminate\Http\JsonResponse;
use Pusher\Pusher;
use Ratchet\ConnectionInterface;
class PrivateChannelTest extends TestCase
@ -238,13 +239,13 @@ class PrivateChannelTest extends TestCase
'appId' => '1234',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'TestSecret', 'POST', $requestPath, [
'name' => 'some-event',
'channels' => ['private-channel'],
'data' => json_encode(['some-data' => 'yes']),
],
);
));
$request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
@ -279,13 +280,13 @@ class PrivateChannelTest extends TestCase
'appId' => '1234',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'TestSecret', 'POST', $requestPath, [
'name' => 'some-event',
'channels' => ['private-channel'],
'data' => json_encode(['some-data' => 'yes']),
],
);
));
$request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
@ -327,13 +328,13 @@ class PrivateChannelTest extends TestCase
'appId' => '1234',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'TestSecret', 'POST', $requestPath, [
'name' => 'some-event',
'channels' => ['private-channel'],
'data' => json_encode(['some-data' => 'yes']),
],
);
));
$request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams));

View File

@ -5,6 +5,7 @@ namespace BeyondCode\LaravelWebSockets\Test;
use BeyondCode\LaravelWebSockets\API\TriggerEvent;
use GuzzleHttp\Psr7\Request;
use Illuminate\Http\JsonResponse;
use Pusher\Pusher;
use Ratchet\ConnectionInterface;
class PublicChannelTest extends TestCase
@ -209,41 +210,28 @@ class PublicChannelTest extends TestCase
public function test_it_fires_the_event_to_public_channel()
{
$this->newActiveConnection(['public-channel']);
$connection = new Mocks\Connection;
$this->startServer();
$requestPath = '/apps/1234/events';
$routeParams = [
'appId' => '1234',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'TestSecret', 'POST', $requestPath, [
'name' => 'some-event',
'channels' => ['public-channel'],
'data' => json_encode(['some-data' => 'yes']),
],
);
));
$request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
$response = $this->await($this->browser->post('http://localhost:4000'."{$requestPath}?{$queryString}"));
$controller = app(TriggerEvent::class);
$controller->onOpen($connection, $request);
/** @var JsonResponse $response */
$response = array_pop($connection->sentRawData);
$this->assertSame([], json_decode($response->getContent(), true));
$this->assertSame([], json_decode((string) $response->getBody(), true));
$this->statisticsCollector
->getAppStatistics('1234')
->then(function ($statistic) {
$this->assertEquals([
'peak_connections_count' => 1,
'websocket_messages_count' => 1,
'peak_connections_count' => 0,
'websocket_messages_count' => 0,
'api_messages_count' => 1,
'app_id' => '1234',
], $statistic->toArray());
@ -260,13 +248,13 @@ class PublicChannelTest extends TestCase
'appId' => '1234',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'TestSecret', 'POST', $requestPath, [
'name' => 'some-event',
'channels' => ['public-channel'],
'data' => json_encode(['some-data' => 'yes']),
],
);
));
$request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
@ -308,13 +296,13 @@ class PublicChannelTest extends TestCase
'appId' => '1234',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'TestSecret', 'POST', $requestPath, [
'name' => 'some-event',
'channels' => ['public-channel'],
'data' => json_encode(['some-data' => 'yes']),
],
);
));
$request = new Request('POST', "{$requestPath}?{$queryString}&".http_build_query($routeParams));

View File

@ -7,14 +7,39 @@ use BeyondCode\LaravelWebSockets\Contracts\StatisticsCollector;
use BeyondCode\LaravelWebSockets\Contracts\StatisticsStore;
use BeyondCode\LaravelWebSockets\Facades\WebSocketRouter;
use BeyondCode\LaravelWebSockets\Helpers;
use BeyondCode\LaravelWebSockets\Server\Loggers\HttpLogger;
use BeyondCode\LaravelWebSockets\Server\Loggers\WebSocketsLogger;
use BeyondCode\LaravelWebSockets\ServerFactory;
use function Clue\React\Block\await;
use Clue\React\Buzz\Browser;
use GuzzleHttp\Psr7\Request;
use Illuminate\Support\Facades\Redis;
use Orchestra\Testbench\BrowserKit\TestCase as Orchestra;
use Pusher\Pusher;
use Ratchet\Server\IoServer;
use React\EventLoop\Factory as LoopFactory;
use React\EventLoop\LoopInterface;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use Symfony\Component\Console\Output\BufferedOutput;
abstract class TestCase extends Orchestra
{
const AWAIT_TIMEOUT = 5.0;
/**
* The test Browser.
*
* @var \Clue\React\Buzz\Browser
*/
protected $browser;
/**
* The test WebSocket server.
*
* @var IoServer
*/
protected $server;
/**
* A test Pusher server.
*
@ -73,11 +98,31 @@ abstract class TestCase extends Orchestra
$this->loop = LoopFactory::create();
$this->app->singleton(LoopInterface::class, function () {
return $this->loop;
});
$this->browser = (new Browser($this->loop))
->withFollowRedirects(false)
->withRejectErrorResponse(false);
$this->app->singleton(HttpLogger::class, function () {
return (new HttpLogger(new BufferedOutput()))
->enable(false)
->verbose(false);
});
$this->app->singleton(WebSocketsLogger::class, function () {
return (new WebSocketsLogger(new BufferedOutput()))
->enable(false)
->verbose(false);
});
$this->replicationMode = getenv('REPLICATION_MODE') ?: 'local';
$this->resetDatabase();
$this->loadLaravelMigrations(['--database' => 'sqlite']);
$this->loadMigrationsFrom(__DIR__.'/database/migrations');
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
$this->withFactories(__DIR__.'/database/factories');
$this->registerCustomPath();
@ -102,6 +147,15 @@ abstract class TestCase extends Orchestra
}
}
protected function tearDown(): void
{
parent::tearDown();
if ($this->server) {
$this->server->socket->close();
}
}
/**
* {@inheritdoc}
*/
@ -270,6 +324,11 @@ abstract class TestCase extends Orchestra
$this->channelManager = $this->app->make(ChannelManager::class);
}
protected function await(PromiseInterface $promise, LoopInterface $loop = null, $timeout = null)
{
return await($promise, $loop ?? $this->loop, $timeout ?? static::AWAIT_TIMEOUT);
}
/**
* Unregister the managers for testing purposes.
*
@ -338,6 +397,19 @@ abstract class TestCase extends Orchestra
return $connection;
}
protected function joinWebSocketServer(array $channelsToJoin = [], string $appKey = 'TestKey', array $headers = [])
{
$promise = new Deferred();
\Ratchet\Client\connect("ws://localhost:4000/app/{$appKey}", [], [], $this->loop)->then(function ($conn) use ($promise) {
$conn->on('message', function ($msg) use ($promise) {
$promise->resolve($msg);
});
});
return $promise->promise();
}
/**
* Get a connected websocket connection.
*
@ -485,27 +557,16 @@ abstract class TestCase extends Orchestra
}
}
protected static function build_auth_query_string(
$auth_key,
$auth_secret,
$request_method,
$request_path,
$query_params = [],
$auth_version = '1.0',
$auth_timestamp = null
) {
$method = method_exists(Pusher::class, 'build_auth_query_params') ? 'build_auth_query_params' : 'build_auth_query_string';
protected function startServer()
{
$server = new ServerFactory('0.0.0.0', 4000);
$params = Pusher::$method(
$auth_key, $auth_secret, $request_method, $request_path, $query_params, $auth_version, $auth_timestamp
);
WebSocketRouter::registerRoutes();
if ($method == 'build_auth_query_string') {
return $params;
}
ksort($params);
return http_build_query($params);
$this->server = $server
->setLoop($this->loop)
->withRoutes(WebSocketRouter::getRoutes())
->setConsoleOutput(new BufferedOutput())
->createServer();
}
}

View File

@ -2,33 +2,25 @@
namespace BeyondCode\LaravelWebSockets\Test;
use BeyondCode\LaravelWebSockets\API\TriggerEvent;
use GuzzleHttp\Psr7\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Pusher\Pusher;
class TriggerEventTest extends TestCase
{
public function test_invalid_signatures_can_not_fire_the_event()
{
$this->expectException(HttpException::class);
$this->expectExceptionMessage('Invalid auth signature provided.');
$this->startServer();
$connection = new Mocks\Connection;
$requestPath = '/apps/1234/events';
$routeParams = [
'appId' => '1234',
];
$queryString = self::build_auth_query_string(
$queryString = http_build_query(Pusher::build_auth_query_params(
'TestKey', 'InvalidSecret', 'GET', $requestPath
);
));
$request = new Request('GET', "{$requestPath}?{$queryString}&".http_build_query($routeParams));
$response = $this->await($this->browser->get('http://localhost:4000'."{$requestPath}?{$queryString}"));
$controller = app(TriggerEvent::class);
$controller->onOpen($connection, $request);
$this->assertSame(405, $response->getStatusCode());
$this->assertSame('', $response->getBody()->getContents());
}
}

View File

@ -1,35 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateWebSocketsStatisticsEntriesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('websockets_statistics_entries', function (Blueprint $table) {
$table->increments('id');
$table->string('app_id');
$table->integer('peak_connections_count');
$table->integer('websocket_messages_count');
$table->integer('api_messages_count');
$table->nullableTimestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('websockets_statistics_entries');
}
}