8889841cREADME.md000066600000003530150515642310006032 0ustar00# Laravel WebSockets 🛰 [![Latest Version on Packagist](https://img.shields.io/packagist/v/beyondcode/laravel-websockets.svg?style=flat-square)](https://packagist.org/packages/beyondcode/laravel-websockets) ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/beyondcode/laravel-websockets/run-tests?label=tests) [![Quality Score](https://img.shields.io/scrutinizer/g/beyondcode/laravel-websockets.svg?style=flat-square)](https://scrutinizer-ci.com/g/beyondcode/laravel-websockets) [![Total Downloads](https://img.shields.io/packagist/dt/beyondcode/laravel-websockets.svg?style=flat-square)](https://packagist.org/packages/beyondcode/laravel-websockets) Bring the power of WebSockets to your Laravel application. Drop-in Pusher replacement, SSL support, Laravel Echo support and a debug dashboard are just some of its features. [![https://phppackagedevelopment.com](https://beyondco.de/courses/phppd.jpg)](https://phppackagedevelopment.com) If you want to learn how to create reusable PHP packages yourself, take a look at my upcoming [PHP Package Development](https://phppackagedevelopment.com) video course. ## Documentation For installation instructions, in-depth usage and deployment details, please take a look at the [official documentation](https://docs.beyondco.de/laravel-websockets/). ### Changelog Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. ## Contributing Please see [CONTRIBUTING](CONTRIBUTING.md) for details. ### Security If you discover any security related issues, please email marcel@beyondco.de instead of using the issue tracker. ## Credits - [Marcel Pociot](https://github.com/mpociot) - [Freek Van der Herten](https://github.com/freekmurze) - [All Contributors](../../contributors) ## License The MIT License (MIT). Please see [License File](LICENSE.md) for more information. LICENSE.md000066600000002112150515642310006152 0ustar00The MIT License (MIT) Copyright (c) Beyond Code GmbH Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. config/websockets.php000066600000011354150515642310010705 0ustar00 [ 'port' => env('LARAVEL_WEBSOCKETS_PORT', 6001), ], /* * This package comes with multi tenancy out of the box. Here you can * configure the different apps that can use the webSockets server. * * Optionally you specify capacity so you can limit the maximum * concurrent connections for a specific app. * * Optionally you can disable client events so clients cannot send * messages to each other via the webSockets. */ 'apps' => [ [ 'id' => env('PUSHER_APP_ID'), 'name' => env('APP_NAME'), 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'path' => env('PUSHER_APP_PATH'), 'capacity' => null, 'enable_client_messages' => false, 'enable_statistics' => true, ], ], /* * This class is responsible for finding the apps. The default provider * will use the apps defined in this config file. * * You can create a custom provider by implementing the * `AppProvider` interface. */ 'app_provider' => BeyondCode\LaravelWebSockets\Apps\ConfigAppProvider::class, /* * This array contains the hosts of which you want to allow incoming requests. * Leave this empty if you want to accept requests from all hosts. */ 'allowed_origins' => [ // ], /* * The maximum request size in kilobytes that is allowed for an incoming WebSocket request. */ 'max_request_size_in_kb' => 250, /* * This path will be used to register the necessary routes for the package. */ 'path' => 'laravel-websockets', /* * Dashboard Routes Middleware * * These middleware will be assigned to every dashboard route, giving you * the chance to add your own middleware to this list or change any of * the existing middleware. Or, you can simply stick with this list. */ 'middleware' => [ 'web', Authorize::class, ], 'statistics' => [ /* * This model will be used to store the statistics of the WebSocketsServer. * The only requirement is that the model should extend * `WebSocketsStatisticsEntry` provided by this package. */ 'model' => \BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry::class, /** * The Statistics Logger will, by default, handle the incoming statistics, store them * and then release them into the database on each interval defined below. */ 'logger' => BeyondCode\LaravelWebSockets\Statistics\Logger\HttpStatisticsLogger::class, /* * Here you can specify the interval in seconds at which statistics should be logged. */ 'interval_in_seconds' => 60, /* * When the clean-command is executed, all recorded statistics older than * the number of days specified here will be deleted. */ 'delete_statistics_older_than_days' => 60, /* * Use an DNS resolver to make the requests to the statistics logger * default is to resolve everything to 127.0.0.1. */ 'perform_dns_lookup' => false, ], /* * Define the optional SSL context for your WebSocket connections. * You can see all available options at: http://php.net/manual/en/context.ssl.php */ 'ssl' => [ /* * Path to local certificate file on filesystem. It must be a PEM encoded file which * contains your certificate and private key. It can optionally contain the * certificate chain of issuers. The private key also may be contained * in a separate file specified by local_pk. */ 'local_cert' => env('LARAVEL_WEBSOCKETS_SSL_LOCAL_CERT', null), /* * Path to local private key file on filesystem in case of separate files for * certificate (local_cert) and private key. */ 'local_pk' => env('LARAVEL_WEBSOCKETS_SSL_LOCAL_PK', null), /* * Passphrase for your local_cert file. */ 'passphrase' => env('LARAVEL_WEBSOCKETS_SSL_PASSPHRASE', null), ], /* * Channel Manager * This class handles how channel persistence is handled. * By default, persistence is stored in an array by the running webserver. * The only requirement is that the class should implement * `ChannelManager` interface provided by this package. */ 'channel_manager' => \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\ArrayChannelManager::class, ]; database/migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php000066600000001536150515642310024112 0ustar00increments('id'); $table->string('app_id'); $table->integer('peak_connection_count'); $table->integer('websocket_message_count'); $table->integer('api_message_count'); $table->nullableTimestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('websockets_statistics_entries'); } } CHANGELOG.md000066600000000651150515642310006365 0ustar00# Changelog All notable changes to `laravel-websockets` will be documented in this file ## 1.4.0 - 2020-03-03 - add support for Laravel 7 ## 1.0.2 - 2018-12-06 - Fix issue with wrong namespaces ## 1.0.1 - 2018-12-04 - Remove VueJS debug mode on dashboard - Allow setting app hosts to use when connecting via the dashboard - Added debug mode when starting the WebSocket server ## 1.0.0 - 2018-12-04 - initial release src/Dashboard/Http/Controllers/ShowDashboard.php000066600000000642150515642310015720 0ustar00 $apps->all(), 'port' => config('websockets.dashboard.port', 6001), ]); } } src/Dashboard/Http/Controllers/SendMessage.php000066600000002206150515642310015364 0ustar00validate([ 'appId' => ['required', new AppId()], 'key' => 'required', 'secret' => 'required', 'channel' => 'required', 'event' => 'required', 'data' => 'json', ]); $this->getPusherBroadcaster($validated)->broadcast( [$validated['channel']], $validated['event'], json_decode($validated['data'], true) ); return 'ok'; } protected function getPusherBroadcaster(array $validated): PusherBroadcaster { $pusher = new Pusher( $validated['key'], $validated['secret'], $validated['appId'], config('broadcasting.connections.pusher.options', []) ); return new PusherBroadcaster($pusher); } } src/Dashboard/Http/Controllers/AuthenticateDashboard.php000066600000001676150515642310017426 0ustar00header('x-app-id')); $broadcaster = new PusherBroadcaster(new Pusher( $app->key, $app->secret, $app->id, [] )); /* * Since the dashboard itself is already secured by the * Authorize middleware, we can trust all channel * authentication requests in here. */ return $broadcaster->validAuthenticationResponse($request, []); } } src/Dashboard/Http/Controllers/DashboardApiController.php000066600000002520150515642310017552 0ustar00latest()->limit(120)->get(); $statisticData = $statistics->map(function ($statistic) { return [ 'timestamp' => (string) $statistic->created_at, 'peak_connection_count' => $statistic->peak_connection_count, 'websocket_message_count' => $statistic->websocket_message_count, 'api_message_count' => $statistic->api_message_count, ]; })->reverse(); return [ 'peak_connections' => [ 'x' => $statisticData->pluck('timestamp'), 'y' => $statisticData->pluck('peak_connection_count'), ], 'websocket_message_count' => [ 'x' => $statisticData->pluck('timestamp'), 'y' => $statisticData->pluck('websocket_message_count'), ], 'api_message_count' => [ 'x' => $statisticData->pluck('timestamp'), 'y' => $statisticData->pluck('api_message_count'), ], ]; } } src/Dashboard/Http/Middleware/Authorize.php000066600000000446150515642310014713 0ustar00user()]) ? $next($request) : abort(403); } } src/Dashboard/DashboardLogger.php000066600000006005150515642310012771 0ustar00httpRequest; static::log($connection->app->id, static::TYPE_CONNECTION, [ 'details' => "Origin: {$request->getUri()->getScheme()}://{$request->getUri()->getHost()}", 'socketId' => $connection->socketId, ]); } public static function occupied(ConnectionInterface $connection, string $channelName) { static::log($connection->app->id, static::TYPE_OCCUPIED, [ 'details' => "Channel: {$channelName}", ]); } public static function subscribed(ConnectionInterface $connection, string $channelName) { static::log($connection->app->id, static::TYPE_SUBSCRIBED, [ 'socketId' => $connection->socketId, 'details' => "Channel: {$channelName}", ]); } public static function clientMessage(ConnectionInterface $connection, stdClass $payload) { static::log($connection->app->id, static::TYPE_CLIENT_MESSAGE, [ 'details' => "Channel: {$payload->channel}, Event: {$payload->event}", 'socketId' => $connection->socketId, 'data' => json_encode($payload), ]); } public static function disconnection(ConnectionInterface $connection) { static::log($connection->app->id, static::TYPE_DISCONNECTION, [ 'socketId' => $connection->socketId, ]); } public static function vacated(ConnectionInterface $connection, string $channelName) { static::log($connection->app->id, static::TYPE_VACATED, [ 'details' => "Channel: {$channelName}", ]); } public static function apiMessage($appId, string $channel, string $event, string $payload) { static::log($appId, static::TYPE_API_MESSAGE, [ 'details' => "Channel: {$channel}, Event: {$event}", 'data' => $payload, ]); } public static function log($appId, string $type, array $attributes = []) { $channelName = static::LOG_CHANNEL_PREFIX.$type; $channel = app(ChannelManager::class)->find($appId, $channelName); optional($channel)->broadcast([ 'event' => 'log-message', 'channel' => $channelName, 'data' => [ 'type' => $type, 'time' => date('H:i:s'), ] + $attributes, ]); } } src/QueryParameters.php000066600000001267150515642310011211 0ustar00request = $request; } public function all(): array { $queryParameters = []; parse_str($this->request->getUri()->getQuery(), $queryParameters); return $queryParameters; } public function get(string $name): string { return $this->all()[$name] ?? ''; } } src/Statistics/Events/StatisticsUpdated.php000066600000003001150515642310015103 0ustar00webSocketsStatisticsEntry = $webSocketsStatisticsEntry; } public function broadcastWith() { return [ 'time' => (string) $this->webSocketsStatisticsEntry->created_at, 'app_id' => $this->webSocketsStatisticsEntry->app_id, 'peak_connection_count' => $this->webSocketsStatisticsEntry->peak_connection_count, 'websocket_message_count' => $this->webSocketsStatisticsEntry->websocket_message_count, 'api_message_count' => $this->webSocketsStatisticsEntry->api_message_count, ]; } public function broadcastOn() { $channelName = Str::after(DashboardLogger::LOG_CHANNEL_PREFIX.'statistics', 'private-'); return new PrivateChannel($channelName); } public function broadcastAs() { return 'statistics-updated'; } } src/Statistics/Http/Controllers/WebSocketStatisticsEntriesController.php000066600000001572150515642310022775 0ustar00validate([ 'app_id' => ['required', new AppId()], 'peak_connection_count' => 'required|integer', 'websocket_message_count' => 'required|integer', 'api_message_count' => 'required|integer', ]); $webSocketsStatisticsEntryModelClass = config('websockets.statistics.model'); $statisticModel = $webSocketsStatisticsEntryModelClass::create($validatedAttributes); broadcast(new StatisticsUpdated($statisticModel)); return 'ok'; } } src/Statistics/Http/Middleware/Authorize.php000066600000000437150515642310015156 0ustar00secret)) ? abort(403) : $next($request); } } src/Statistics/Models/WebSocketsStatisticsEntry.php000066600000000363150515642310016577 0ustar00findById($value) ? true : false; } public function message() { return 'There is no app registered with the given id. Make sure the websockets config file contains an app for this id or that your custom AppProvider returns an app for this id.'; } } src/Statistics/DnsResolver.php000066600000001460150515642310012453 0ustar00resolveInternal($domain); } public function resolveAll($domain, $type) { return $this->resolveInternal($domain, $type); } private function resolveInternal($domain, $type = null) { return new FulfilledPromise($this->internalIP); } public function __toString() { return $this->internalIP; } } src/Statistics/Statistic.php000066600000003323150515642310012154 0ustar00appId = $appId; } public function isEnabled(): bool { return App::findById($this->appId)->statisticsEnabled; } public function connection() { $this->currentConnectionCount++; $this->peakConnectionCount = max($this->currentConnectionCount, $this->peakConnectionCount); } public function disconnection() { $this->currentConnectionCount--; $this->peakConnectionCount = max($this->currentConnectionCount, $this->peakConnectionCount); } public function webSocketMessage() { $this->webSocketMessageCount++; } public function apiMessage() { $this->apiMessageCount++; } public function reset(int $currentConnectionCount) { $this->currentConnectionCount = $currentConnectionCount; $this->peakConnectionCount = $currentConnectionCount; $this->webSocketMessageCount = 0; $this->apiMessageCount = 0; } public function toArray() { return [ 'app_id' => $this->appId, 'peak_connection_count' => $this->peakConnectionCount, 'websocket_message_count' => $this->webSocketMessageCount, 'api_message_count' => $this->apiMessageCount, ]; } } src/Statistics/Logger/HttpStatisticsLogger.php000066600000005150150515642310015556 0ustar00channelManager = $channelManager; $this->browser = $browser; } public function webSocketMessage(ConnectionInterface $connection) { $this ->findOrMakeStatisticForAppId($connection->app->id) ->webSocketMessage(); } public function apiMessage($appId) { $this ->findOrMakeStatisticForAppId($appId) ->apiMessage(); } public function connection(ConnectionInterface $connection) { $this ->findOrMakeStatisticForAppId($connection->app->id) ->connection(); } public function disconnection(ConnectionInterface $connection) { $this ->findOrMakeStatisticForAppId($connection->app->id) ->disconnection(); } protected function findOrMakeStatisticForAppId($appId): Statistic { if (! isset($this->statistics[$appId])) { $this->statistics[$appId] = new Statistic($appId); } return $this->statistics[$appId]; } public function save() { foreach ($this->statistics as $appId => $statistic) { if (! $statistic->isEnabled()) { continue; } $postData = array_merge($statistic->toArray(), [ 'secret' => App::findById($appId)->secret, ]); $this ->browser ->post( action([WebSocketStatisticsEntriesController::class, 'store']), ['Content-Type' => 'application/json'], Utils::streamFor(json_encode($postData)) ); $currentConnectionCount = $this->channelManager->getConnectionCount($appId); $statistic->reset($currentConnectionCount); } } } src/Statistics/Logger/StatisticsLogger.php000066600000000627150515642310014722 0ustar00findById($appId); } public static function findByKey(string $appKey): ?self { return app(AppProvider::class)->findByKey($appKey); } public static function findBySecret(string $appSecret): ?self { return app(AppProvider::class)->findBySecret($appSecret); } public function __construct($appId, string $appKey, string $appSecret) { if ($appKey === '') { throw InvalidApp::valueIsRequired('appKey', $appId); } if ($appSecret === '') { throw InvalidApp::valueIsRequired('appSecret', $appId); } $this->id = $appId; $this->key = $appKey; $this->secret = $appSecret; } public function setName(string $appName) { $this->name = $appName; return $this; } public function setHost(string $host) { $this->host = $host; return $this; } public function setPath(string $path) { $this->path = $path; return $this; } public function enableClientMessages(bool $enabled = true) { $this->clientMessagesEnabled = $enabled; return $this; } public function setCapacity(?int $capacity) { $this->capacity = $capacity; return $this; } public function enableStatistics(bool $enabled = true) { $this->statisticsEnabled = $enabled; return $this; } } src/Apps/ConfigAppProvider.php000066600000004026150515642310012340 0ustar00apps = collect(config('websockets.apps')); } /** @return array[\BeyondCode\LaravelWebSockets\AppProviders\App] */ public function all(): array { return $this->apps ->map(function (array $appAttributes) { return $this->instanciate($appAttributes); }) ->toArray(); } public function findById($appId): ?App { $appAttributes = $this ->apps ->firstWhere('id', $appId); return $this->instanciate($appAttributes); } public function findByKey(string $appKey): ?App { $appAttributes = $this ->apps ->firstWhere('key', $appKey); return $this->instanciate($appAttributes); } public function findBySecret(string $appSecret): ?App { $appAttributes = $this ->apps ->firstWhere('secret', $appSecret); return $this->instanciate($appAttributes); } protected function instanciate(?array $appAttributes): ?App { if (! $appAttributes) { return null; } $app = new App( $appAttributes['id'], $appAttributes['key'], $appAttributes['secret'] ); if (isset($appAttributes['name'])) { $app->setName($appAttributes['name']); } if (isset($appAttributes['host'])) { $app->setHost($appAttributes['host']); } if (isset($appAttributes['path'])) { $app->setPath($appAttributes['path']); } $app ->enableClientMessages($appAttributes['enable_client_messages']) ->enableStatistics($appAttributes['enable_statistics']) ->setCapacity($appAttributes['capacity'] ?? null); return $app; } } src/WebSockets/Messages/PusherMessage.php000066600000000177150515642310014452 0ustar00payload = $payload; $this->connection = $connection; $this->channelManager = $channelManager; } public function respond() { if (! Str::startsWith($this->payload->event, 'client-')) { return; } if (! $this->connection->app->clientMessagesEnabled) { return; } DashboardLogger::clientMessage($this->connection, $this->payload); $channel = $this->channelManager->find($this->connection->app->id, $this->payload->channel); optional($channel)->broadcastToOthers($this->connection, $this->payload); } } src/WebSockets/Messages/PusherChannelProtocolMessage.php000066600000003554150515642310017467 0ustar00payload = $payload; $this->connection = $connection; $this->channelManager = $channelManager; } public function respond() { $eventName = Str::camel(Str::after($this->payload->event, ':')); if (method_exists($this, $eventName) && $eventName !== 'respond') { call_user_func([$this, $eventName], $this->connection, $this->payload->data ?? new stdClass()); } } /* * @link https://pusher.com/docs/pusher_protocol#ping-pong */ protected function ping(ConnectionInterface $connection) { $connection->send(json_encode([ 'event' => 'pusher:pong', ])); } /* * @link https://pusher.com/docs/pusher_protocol#pusher-subscribe */ protected function subscribe(ConnectionInterface $connection, stdClass $payload) { $channel = $this->channelManager->findOrCreate($connection->app->id, $payload->channel); $channel->subscribe($connection, $payload); } public function unsubscribe(ConnectionInterface $connection, stdClass $payload) { $channel = $this->channelManager->findOrCreate($connection->app->id, $payload->channel); $channel->unsubscribe($connection); } } src/WebSockets/Messages/PusherMessageFactory.php000066600000001354150515642310016000 0ustar00getPayload()); return Str::startsWith($payload->event, 'pusher:') ? new PusherChannelProtocolMessage($payload, $connection, $channelManager) : new PusherClientMessage($payload, $connection, $channelManager); } } src/WebSockets/WebSocketHandler.php000066600000007046150515642310013316 0ustar00channelManager = $channelManager; } public function onOpen(ConnectionInterface $connection) { $this ->verifyAppKey($connection) ->limitConcurrentConnections($connection) ->generateSocketId($connection) ->establishConnection($connection); } public function onMessage(ConnectionInterface $connection, MessageInterface $message) { $message = PusherMessageFactory::createForMessage($message, $connection, $this->channelManager); $message->respond(); StatisticsLogger::webSocketMessage($connection); } public function onClose(ConnectionInterface $connection) { $this->channelManager->removeFromAllChannels($connection); DashboardLogger::disconnection($connection); StatisticsLogger::disconnection($connection); } public function onError(ConnectionInterface $connection, Exception $exception) { if ($exception instanceof WebSocketException) { $connection->send(json_encode( $exception->getPayload() )); } } protected function verifyAppKey(ConnectionInterface $connection) { $appKey = QueryParameters::create($connection->httpRequest)->get('appKey'); if (! $app = App::findByKey($appKey)) { throw new UnknownAppKey($appKey); } $connection->app = $app; return $this; } protected function limitConcurrentConnections(ConnectionInterface $connection) { if (! is_null($capacity = $connection->app->capacity)) { $connectionsCount = $this->channelManager->getConnectionCount($connection->app->id); if ($connectionsCount >= $capacity) { throw new ConnectionsOverCapacity(); } } return $this; } protected function generateSocketId(ConnectionInterface $connection) { $socketId = sprintf('%d.%d', random_int(1, 1000000000), random_int(1, 1000000000)); $connection->socketId = $socketId; return $this; } protected function establishConnection(ConnectionInterface $connection) { $connection->send(json_encode([ 'event' => 'pusher:connection_established', 'data' => json_encode([ 'socket_id' => $connection->socketId, 'activity_timeout' => 30, ]), ])); DashboardLogger::connection($connection); StatisticsLogger::connection($connection); return $this; } } src/WebSockets/Channels/ChannelManager.php000066600000000735150515642310014526 0ustar00channelName = $channelName; } public function getName(): string { return $this->channelName; } public function hasConnections(): bool { return count($this->subscribedConnections) > 0; } public function getSubscribedConnections(): array { return $this->subscribedConnections; } protected function verifySignature(ConnectionInterface $connection, stdClass $payload) { $signature = "{$connection->socketId}:{$this->channelName}"; if (isset($payload->channel_data)) { $signature .= ":{$payload->channel_data}"; } if (Str::after($payload->auth, ':') !== hash_hmac('sha256', $signature, $connection->app->secret)) { throw new InvalidSignature(); } } /* * @link https://pusher.com/docs/pusher_protocol#presence-channel-events */ public function subscribe(ConnectionInterface $connection, stdClass $payload) { $this->saveConnection($connection); $connection->send(json_encode([ 'event' => 'pusher_internal:subscription_succeeded', 'channel' => $this->channelName, ])); } public function unsubscribe(ConnectionInterface $connection) { unset($this->subscribedConnections[$connection->socketId]); if (! $this->hasConnections()) { DashboardLogger::vacated($connection, $this->channelName); } } protected function saveConnection(ConnectionInterface $connection) { $hadConnectionsPreviously = $this->hasConnections(); $this->subscribedConnections[$connection->socketId] = $connection; if (! $hadConnectionsPreviously) { DashboardLogger::occupied($connection, $this->channelName); } DashboardLogger::subscribed($connection, $this->channelName); } public function broadcast($payload) { foreach ($this->subscribedConnections as $connection) { $connection->send(json_encode($payload)); } } public function broadcastToOthers(ConnectionInterface $connection, $payload) { $this->broadcastToEveryoneExcept($payload, $connection->socketId); } public function broadcastToEveryoneExcept($payload, ?string $socketId = null) { if (is_null($socketId)) { return $this->broadcast($payload); } foreach ($this->subscribedConnections as $connection) { if ($connection->socketId !== $socketId) { $connection->send(json_encode($payload)); } } } public function toArray(): array { return [ 'occupied' => count($this->subscribedConnections) > 0, 'subscription_count' => count($this->subscribedConnections), ]; } } src/WebSockets/Channels/PrivateChannel.php000066600000000545150515642310014565 0ustar00verifySignature($connection, $payload); parent::subscribe($connection, $payload); } } src/WebSockets/Channels/ChannelManagers/ArrayChannelManager.php000066600000005360150515642310020552 0ustar00channels[$appId][$channelName])) { $channelClass = $this->determineChannelClass($channelName); $this->channels[$appId][$channelName] = new $channelClass($channelName); } return $this->channels[$appId][$channelName]; } public function find(string $appId, string $channelName): ?Channel { return $this->channels[$appId][$channelName] ?? null; } protected function determineChannelClass(string $channelName): string { if (Str::startsWith($channelName, 'private-')) { return PrivateChannel::class; } if (Str::startsWith($channelName, 'presence-')) { return PresenceChannel::class; } return Channel::class; } public function getChannels(string $appId): array { return $this->channels[$appId] ?? []; } public function getConnectionCount(string $appId): int { return collect($this->getChannels($appId)) ->flatMap(function (Channel $channel) { return collect($channel->getSubscribedConnections())->pluck('socketId'); }) ->unique() ->count(); } public function removeFromAllChannels(ConnectionInterface $connection) { if (! isset($connection->app)) { return; } /* * Remove the connection from all channels. */ collect(Arr::get($this->channels, $connection->app->id, []))->each->unsubscribe($connection); /* * Unset all channels that have no connections so we don't leak memory. */ collect(Arr::get($this->channels, $connection->app->id, [])) ->reject->hasConnections() ->each(function (Channel $channel, string $channelName) use ($connection) { unset($this->channels[$connection->app->id][$channelName]); }); if (count(Arr::get($this->channels, $connection->app->id, [])) === 0) { unset($this->channels[$connection->app->id]); } } } src/WebSockets/Channels/PresenceChannel.php000066600000007740150515642310014723 0ustar00 */ protected $users = []; /** * List of sockets keyed by their ID with the value pointing to a user ID. * * @var array */ protected $sockets = []; public function subscribe(ConnectionInterface $connection, stdClass $payload) { $this->verifySignature($connection, $payload); $this->saveConnection($connection); $channelData = json_decode($payload->channel_data, true); // The ID of the user connecting $userId = (string) $channelData['user_id']; // Check if the user was already connected to the channel before storing the connection in the state $userFirstConnection = ! isset($this->users[$userId]); // Add or replace the user info in the state $this->users[$userId] = $channelData['user_info'] ?? []; // Add the socket ID to user ID map in the state $this->sockets[$connection->socketId] = $userId; // Send the success event $connection->send(json_encode([ 'event' => 'pusher_internal:subscription_succeeded', 'channel' => $this->channelName, 'data' => json_encode($this->getChannelData()), ])); // The `pusher_internal:member_added` event is triggered when a user joins a channel. // It's quite possible that a user can have multiple connections to the same channel // (for example by having multiple browser tabs open) // and in this case the events will only be triggered when the first tab is opened. if ($userFirstConnection) { $this->broadcastToOthers($connection, [ 'event' => 'pusher_internal:member_added', 'channel' => $this->channelName, 'data' => json_encode($channelData), ]); } } public function unsubscribe(ConnectionInterface $connection) { parent::unsubscribe($connection); if (! isset($this->sockets[$connection->socketId])) { return; } // Find the user ID belonging to this socket $userId = $this->sockets[$connection->socketId]; // Remove the socket from the state unset($this->sockets[$connection->socketId]); // Test if the user still has open sockets to this channel $userHasOpenConnections = (array_flip($this->sockets)[$userId] ?? null) !== null; // The `pusher_internal:member_removed` is triggered when a user leaves a channel. // It's quite possible that a user can have multiple connections to the same channel // (for example by having multiple browser tabs open) // and in this case the events will only be triggered when the last one is closed. if (! $userHasOpenConnections) { $this->broadcastToOthers($connection, [ 'event' => 'pusher_internal:member_removed', 'channel' => $this->channelName, 'data' => json_encode([ 'user_id' => $userId, ]), ]); // Remove the user info from the state unset($this->users[$userId]); } } protected function getChannelData(): array { return [ 'presence' => [ 'ids' => array_keys($this->users), 'hash' => $this->users, 'count' => count($this->users), ], ]; } public function getUsers(): array { return $this->users; } public function toArray(): array { return array_merge(parent::toArray(), [ 'user_count' => count($this->users), ]); } } src/WebSockets/Exceptions/UnknownAppKey.php000066600000000422150515642310015013 0ustar00message = "Could not find app key `{$appKey}`."; $this->code = 4001; } } src/WebSockets/Exceptions/WebSocketException.php000066600000000572150515642310016015 0ustar00 'pusher:error', 'data' => [ 'message' => $this->getMessage(), 'code' => $this->getCode(), ], ]; } } src/WebSockets/Exceptions/ConnectionsOverCapacity.php000066600000000715150515642310017043 0ustar00message = 'Over capacity'; // @See https://pusher.com/docs/pusher_protocol#error-codes // Indicates an error resulting in the connection // being closed by Pusher, and that the client may reconnect after 1s or more. $this->code = 4100; } } src/WebSockets/Exceptions/InvalidConnection.php000066600000000367150515642310015660 0ustar00message = 'Invalid Connection'; $this->code = 4009; } } src/WebSockets/Exceptions/InvalidSignature.php000066600000000365150515642310015520 0ustar00message = 'Invalid Signature'; $this->code = 4009; } } src/Exceptions/InvalidWebSocketController.php000066600000000743150515642310015440 0ustar00setSolutionDescription('Make sure that your `config/websockets.php` contains the app key you are trying to use.') ->setDocumentationLinks([ 'Configuring WebSocket Apps (official documentation)' => 'https://docs.beyondco.de/laravel-websockets/1.0/basic-usage/pusher.html#configuring-websocket-apps', ]); } } src/Server/WebSocketServerFactory.php000066600000004446150515642310013735 0ustar00loop = LoopFactory::create(); } public function useRoutes(RouteCollection $routes) { $this->routes = $routes; return $this; } public function setHost(string $host) { $this->host = $host; return $this; } public function setPort(string $port) { $this->port = $port; return $this; } public function setLoop(LoopInterface $loop) { $this->loop = $loop; return $this; } public function setConsoleOutput(OutputInterface $consoleOutput) { $this->consoleOutput = $consoleOutput; return $this; } public function createServer(): IoServer { $socket = new Server("{$this->host}:{$this->port}", $this->loop); if (config('websockets.ssl.local_cert')) { $socket = new SecureServer($socket, $this->loop, config('websockets.ssl')); } $urlMatcher = new UrlMatcher($this->routes, new RequestContext); $router = new Router($urlMatcher); $app = new OriginCheck($router, config('websockets.allowed_origins', [])); $httpServer = new HttpServer($app, config('websockets.max_request_size_in_kb') * 1024); if (HttpLogger::isEnabled()) { $httpServer = HttpLogger::decorate($httpServer); } return new IoServer($httpServer, $socket, $this->loop); } } src/Server/HttpServer.php000066600000000534150515642310011430 0ustar00_reqParser->maxSize = $maxRequestSize; } } src/Server/OriginCheck.php000066600000003274150515642310011513 0ustar00_component = $component; $this->allowedOrigins = $allowedOrigins; } public function onOpen(ConnectionInterface $connection, RequestInterface $request = null) { if ($request->hasHeader('Origin')) { $this->verifyOrigin($connection, $request); } return $this->_component->onOpen($connection, $request); } public function onMessage(ConnectionInterface $from, $msg) { return $this->_component->onMessage($from, $msg); } public function onClose(ConnectionInterface $connection) { return $this->_component->onClose($connection); } public function onError(ConnectionInterface $connection, \Exception $e) { return $this->_component->onError($connection, $e); } protected function verifyOrigin(ConnectionInterface $connection, RequestInterface $request) { $header = (string) $request->getHeader('Origin')[0]; $origin = parse_url($header, PHP_URL_HOST) ?: $header; if (! empty($this->allowedOrigins) && ! in_array($origin, $this->allowedOrigins)) { return $this->close($connection, 403); } } } src/Server/Router.php000066600000006775150515642310010617 0ustar00routes = new RouteCollection; $this->customRoutes = new Collection(); } public function getRoutes(): RouteCollection { return $this->routes; } public function echo() { $this->get('/app/{appKey}', WebSocketHandler::class); $this->post('/apps/{appId}/events', TriggerEventController::class); $this->get('/apps/{appId}/channels', FetchChannelsController::class); $this->get('/apps/{appId}/channels/{channelName}', FetchChannelController::class); $this->get('/apps/{appId}/channels/{channelName}/users', FetchUsersController::class); } public function customRoutes() { $this->customRoutes->each(function ($action, $uri) { $this->get($uri, $action); }); } public function get(string $uri, $action) { $this->addRoute('GET', $uri, $action); } public function post(string $uri, $action) { $this->addRoute('POST', $uri, $action); } public function put(string $uri, $action) { $this->addRoute('PUT', $uri, $action); } public function patch(string $uri, $action) { $this->addRoute('PATCH', $uri, $action); } public function delete(string $uri, $action) { $this->addRoute('DELETE', $uri, $action); } public function webSocket(string $uri, $action) { if (! is_subclass_of($action, MessageComponentInterface::class)) { throw InvalidWebSocketController::withController($action); } $this->customRoutes->put($uri, $action); } public function addRoute(string $method, string $uri, $action) { $this->routes->add($uri, $this->getRoute($method, $uri, $action)); } protected function getRoute(string $method, string $uri, $action): Route { /** * If the given action is a class that handles WebSockets, then it's not a regular * controller but a WebSocketHandler that needs to converted to a WsServer. * * If the given action is a regular controller we'll just instanciate it. */ $action = is_subclass_of($action, MessageComponentInterface::class) ? $this->createWebSocketsServer($action) : app($action); return new Route($uri, ['_controller' => $action], [], [], null, [], [$method]); } protected function createWebSocketsServer(string $action): WsServer { $app = app($action); if (WebsocketsLogger::isEnabled()) { $app = WebsocketsLogger::decorate($app); } return new WsServer($app); } } src/Server/Logger/Logger.php000066600000003050150515642310011754 0ustar00enabled; } public function __construct(OutputInterface $consoleOutput) { $this->consoleOutput = $consoleOutput; } public function enable($enabled = true) { $this->enabled = $enabled; return $this; } public function verbose($verbose = false) { $this->verbose = $verbose; return $this; } protected function info(string $message) { $this->line($message, 'info'); } protected function warn(string $message) { if (! $this->consoleOutput->getFormatter()->hasStyle('warning')) { $style = new OutputFormatterStyle('yellow'); $this->consoleOutput->getFormatter()->setStyle('warning', $style); } $this->line($message, 'warning'); } protected function error(string $message) { $this->line($message, 'error'); } protected function line(string $message, string $style) { $styled = $style ? "<$style>$message" : $message; $this->consoleOutput->writeln($styled); } } src/Server/Logger/ConnectionLogger.php000066600000002547150515642310014006 0ustar00setConnection($app); } public function setConnection(ConnectionInterface $connection) { $this->connection = $connection; return $this; } protected function getConnection() { return $this->connection; } public function send($data) { $socketId = $this->connection->socketId ?? null; $this->info("Connection id {$socketId} sending message {$data}"); $this->connection->send($data); } public function close() { $this->warn("Connection id {$this->connection->socketId} closing."); $this->connection->close(); } public function __set($name, $value) { return $this->connection->$name = $value; } public function __get($name) { return $this->connection->$name; } public function __isset($name) { return isset($this->connection->$name); } public function __unset($name) { unset($this->connection->$name); } } src/Server/Logger/WebsocketsLogger.php000066600000004044150515642310014012 0ustar00setApp($app); } public function setApp(MessageComponentInterface $app) { $this->app = $app; return $this; } public function onOpen(ConnectionInterface $connection) { $appKey = QueryParameters::create($connection->httpRequest)->get('appKey'); $this->warn("New connection opened for app key {$appKey}."); $this->app->onOpen(ConnectionLogger::decorate($connection)); } public function onMessage(ConnectionInterface $connection, MessageInterface $message) { $this->info("{$connection->app->id}: connection id {$connection->socketId} received message: {$message->getPayload()}."); $this->app->onMessage(ConnectionLogger::decorate($connection), $message); } public function onClose(ConnectionInterface $connection) { $socketId = $connection->socketId ?? null; $this->warn("Connection id {$socketId} closed."); $this->app->onClose(ConnectionLogger::decorate($connection)); } public function onError(ConnectionInterface $connection, Exception $exception) { $exceptionClass = get_class($exception); $appId = $connection->app->id ?? 'Unknown app id'; $message = "{$appId}: exception `{$exceptionClass}` thrown: `{$exception->getMessage()}`."; if ($this->verbose) { $message .= $exception->getTraceAsString(); } $this->error($message); $this->app->onError(ConnectionLogger::decorate($connection), $exception); } } src/Server/Logger/HttpLogger.php000066600000002527150515642310012624 0ustar00setApp($app); } public function setApp(MessageComponentInterface $app) { $this->app = $app; return $this; } public function onOpen(ConnectionInterface $connection) { $this->app->onOpen($connection); } public function onMessage(ConnectionInterface $connection, $message) { $this->app->onMessage($connection, $message); } public function onClose(ConnectionInterface $connection) { $this->app->onClose($connection); } public function onError(ConnectionInterface $connection, Exception $exception) { $exceptionClass = get_class($exception); $message = "Exception `{$exceptionClass}` thrown: `{$exception->getMessage()}`"; if ($this->verbose) { $message .= $exception->getTraceAsString(); } $this->error($message); $this->app->onError($connection, $exception); } } src/Facades/WebSocketsRouter.php000066600000000444150515642310012654 0ustar00loop = LoopFactory::create(); } public function handle() { $this ->configureStatisticsLogger() ->configureHttpLogger() ->configureMessageLogger() ->configureConnectionLogger() ->configureRestartTimer() ->registerEchoRoutes() ->registerCustomRoutes() ->startWebSocketServer(); } protected function configureStatisticsLogger() { $connector = new Connector($this->loop, [ 'dns' => $this->getDnsResolver(), 'tls' => [ 'verify_peer' => config('app.env') === 'production', 'verify_peer_name' => config('app.env') === 'production', ], ]); $browser = new Browser($this->loop, $connector); app()->singleton(StatisticsLoggerInterface::class, function ($app) use ($browser) { $config = $app['config']['websockets']; $class = $config['statistics']['logger'] ?? \BeyondCode\LaravelWebSockets\Statistics\Logger\HttpStatisticsLogger::class; return new $class(app(ChannelManager::class), $browser); }); $this->loop->addPeriodicTimer(config('websockets.statistics.interval_in_seconds'), function () { StatisticsLogger::save(); }); return $this; } protected function configureHttpLogger() { app()->singleton(HttpLogger::class, function ($app) { return (new HttpLogger($this->output)) ->enable($this->option('debug') ?: ($app['config']['app']['debug'] ?? false)) ->verbose($this->output->isVerbose()); }); return $this; } protected function configureMessageLogger() { app()->singleton(WebsocketsLogger::class, function ($app) { return (new WebsocketsLogger($this->output)) ->enable($this->option('debug') ?: ($app['config']['app']['debug'] ?? false)) ->verbose($this->output->isVerbose()); }); return $this; } protected function configureConnectionLogger() { app()->bind(ConnectionLogger::class, function ($app) { return (new ConnectionLogger($this->output)) ->enable($app['config']['app']['debug'] ?? false) ->verbose($this->output->isVerbose()); }); return $this; } public function configureRestartTimer() { $this->lastRestart = $this->getLastRestart(); $this->loop->addPeriodicTimer(10, function () { if ($this->getLastRestart() !== $this->lastRestart) { $this->loop->stop(); } }); return $this; } protected function registerEchoRoutes() { WebSocketsRouter::echo(); return $this; } protected function registerCustomRoutes() { WebSocketsRouter::customRoutes(); return $this; } protected function startWebSocketServer() { $this->info("Starting the WebSocket server on port {$this->option('port')}..."); $routes = WebSocketsRouter::getRoutes(); /* 🛰 Start the server 🛰 */ (new WebSocketServerFactory()) ->setLoop($this->loop) ->useRoutes($routes) ->setHost($this->option('host')) ->setPort($this->option('port')) ->setConsoleOutput($this->output) ->createServer() ->run(); } protected function getDnsResolver(): ResolverInterface { if (! config('websockets.statistics.perform_dns_lookup')) { return new DnsResolver; } $dnsConfig = DnsConfig::loadSystemConfigBlocking(); return (new DnsFactory)->createCached( $dnsConfig->nameservers ? reset($dnsConfig->nameservers) : '1.1.1.1', $this->loop ); } protected function getLastRestart() { return Cache::get('beyondcode:websockets:restart', 0); } } src/Console/CleanStatistics.php000066600000002272150515642310012554 0ustar00comment('Cleaning WebSocket Statistics...'); $appId = $this->argument('appId'); $maxAgeInDays = config('websockets.statistics.delete_statistics_older_than_days'); $cutOffDate = Carbon::now()->subDay($maxAgeInDays)->format('Y-m-d H:i:s'); $webSocketsStatisticsEntryModelClass = config('websockets.statistics.model'); $amountDeleted = $webSocketsStatisticsEntryModelClass::where('created_at', '<', $cutOffDate) ->when(! is_null($appId), function (Builder $query) use ($appId) { $query->where('app_id', $appId); }) ->delete(); $this->info("Deleted {$amountDeleted} record(s) from the WebSocket statistics."); $this->comment('All done!'); } } src/Console/RestartWebSocketServer.php000066600000001055150515642310014077 0ustar00currentTime()); $this->info('Broadcasting WebSocket server restart signal.'); } } src/WebSocketsServiceProvider.php000066600000007013150515642310013160 0ustar00publishes([ __DIR__.'/../config/websockets.php' => base_path('config/websockets.php'), ], 'config'); $this->publishes([ __DIR__.'/../database/migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php' => database_path('migrations/0000_00_00_000000_create_websockets_statistics_entries_table.php'), ], 'migrations'); $this ->registerRoutes() ->registerDashboardGate(); $this->loadViewsFrom(__DIR__.'/../resources/views/', 'websockets'); $this->commands([ Console\StartWebSocketServer::class, Console\CleanStatistics::class, Console\RestartWebSocketServer::class, ]); } public function register() { $this->mergeConfigFrom(__DIR__.'/../config/websockets.php', 'websockets'); $this->app->singleton('websockets.router', function () { return new Router(); }); $this->app->singleton(ChannelManager::class, function ($app) { $config = $app['config']['websockets']; return ($config['channel_manager'] ?? null) !== null && class_exists($config['channel_manager']) ? app($config['channel_manager']) : new ArrayChannelManager(); }); $this->app->singleton(AppProvider::class, function ($app) { $config = $app['config']['websockets']; return app($config['app_provider']); }); } protected function registerRoutes() { Route::prefix(config('websockets.path'))->group(function () { Route::middleware(config('websockets.middleware', [AuthorizeDashboard::class]))->group(function () { Route::get('/', ShowDashboard::class); Route::get('/api/{appId}/statistics', [DashboardApiController::class, 'getStatistics']); Route::post('auth', AuthenticateDashboard::class); Route::post('event', SendMessage::class); }); Route::middleware(AuthorizeStatistics::class)->group(function () { Route::post('statistics', [WebSocketStatisticsEntriesController::class, 'store']); }); }); return $this; } protected function registerDashboardGate() { Gate::define('viewWebSocketsDashboard', function ($user = null) { return app()->environment('local'); }); return $this; } } src/HttpApi/Controllers/Controller.php000066600000011405150515642310014055 0ustar00channelManager = $channelManager; } public function onOpen(ConnectionInterface $connection, RequestInterface $request = null) { $this->request = $request; $this->contentLength = $this->findContentLength($request->getHeaders()); $this->requestBuffer = (string) $request->getBody(); $this->checkContentLength($connection); } protected function findContentLength(array $headers): int { return Collection::make($headers)->first(function ($values, $header) { return strtolower($header) === 'content-length'; })[0] ?? 0; } public function onMessage(ConnectionInterface $from, $msg) { $this->requestBuffer .= $msg; $this->checkContentLength($from); } protected function checkContentLength(ConnectionInterface $connection) { if (strlen($this->requestBuffer) === $this->contentLength) { $serverRequest = (new ServerRequest( $this->request->getMethod(), $this->request->getUri(), $this->request->getHeaders(), $this->requestBuffer, $this->request->getProtocolVersion() ))->withQueryParams(QueryParameters::create($this->request)->all()); $laravelRequest = Request::createFromBase((new HttpFoundationFactory)->createRequest($serverRequest)); $this ->ensureValidAppId($laravelRequest->appId) ->ensureValidSignature($laravelRequest); $response = new JsonResponse($this($laravelRequest)); $content = $response->content(); $response->header('Content-Length', strlen($content)); $connection->send($response); $connection->close(); } } public function onClose(ConnectionInterface $connection) { } public function onError(ConnectionInterface $connection, Exception $exception) { if (! $exception instanceof HttpException) { return; } $responseData = json_encode([ 'error' => $exception->getMessage(), ]); $response = new Response($exception->getStatusCode(), [ 'Content-Type' => 'application/json', 'Content-Length' => strlen($responseData), ], $responseData); $connection->send(Message::toString($response)); $connection->close(); } public function ensureValidAppId(string $appId) { if (! App::findById($appId)) { throw new HttpException(401, "Unknown app id `{$appId}` provided."); } return $this; } protected function ensureValidSignature(Request $request) { /* * The `auth_signature` & `body_md5` parameters are not included when calculating the `auth_signature` value. * * The `appId`, `appKey` & `channelName` parameters are actually route paramaters and are never supplied by the client. */ $params = Arr::except($request->query(), ['auth_signature', 'body_md5', 'appId', 'appKey', 'channelName']); if ($request->getContent() !== '') { $params['body_md5'] = md5($request->getContent()); } ksort($params); $signature = "{$request->getMethod()}\n/{$request->path()}\n".Pusher::array_implode('=', '&', $params); $authSignature = hash_hmac('sha256', $signature, App::findById($request->get('appId'))->secret); if ($authSignature !== $request->get('auth_signature')) { throw new HttpException(401, 'Invalid auth signature provided.'); } return $this; } abstract public function __invoke(Request $request); } src/HttpApi/Controllers/FetchChannelsController.php000066600000002633150515642310016506 0ustar00has('info')) { $attributes = explode(',', trim($request->info)); if (in_array('user_count', $attributes) && ! Str::startsWith($request->filter_by_prefix, 'presence-')) { throw new HttpException(400, 'Request must be limited to presence channels in order to fetch user_count'); } } $channels = Collection::make($this->channelManager->getChannels($request->appId)); if ($request->has('filter_by_prefix')) { $channels = $channels->filter(function ($channel, $channelName) use ($request) { return Str::startsWith($channelName, $request->filter_by_prefix); }); } return [ 'channels' => $channels->map(function ($channel) use ($attributes) { $info = new \stdClass; if (in_array('user_count', $attributes)) { $info->user_count = count($channel->getUsers()); } return $info; })->toArray() ?: new \stdClass, ]; } } src/HttpApi/Controllers/FetchChannelController.php000066600000001014150515642310016313 0ustar00channelManager->find($request->appId, $request->channelName); if (is_null($channel)) { throw new HttpException(404, "Unknown channel `{$request->channelName}`."); } return $channel->toArray(); } } src/HttpApi/Controllers/TriggerEventController.php000066600000002570150515642310016406 0ustar00ensureValidSignature($request); $payload = $request->json(); if ($payload->has('channel')) { $channels = [$payload->get('channel')]; } else { $channels = $payload->all()['channels'] ?? []; if (is_string($channels)) { $channels = [$channels]; } } foreach ($channels as $channelName) { $channel = $this->channelManager->find($request->appId, $channelName); optional($channel)->broadcastToEveryoneExcept([ 'channel' => $channelName, 'event' => $request->json()->get('name'), 'data' => $request->json()->get('data'), ], $request->json()->get('socket_id')); DashboardLogger::apiMessage( $request->appId, $channelName, $request->json()->get('name'), $request->json()->get('data') ); StatisticsLogger::apiMessage($request->appId); } return (object) []; } } src/HttpApi/Controllers/FetchUsersController.php000066600000001662150515642310016055 0ustar00channelManager->find($request->appId, $request->channelName); if (is_null($channel)) { throw new HttpException(404, 'Unknown channel "'.$request->channelName.'"'); } if (! $channel instanceof PresenceChannel) { throw new HttpException(400, 'Invalid presence channel "'.$request->channelName.'"'); } return [ 'users' => Collection::make($channel->getUsers())->keys()->map(function ($userId) { return ['id' => $userId]; })->values(), ]; } } .styleci.yml000066600000000102150515642310007020 0ustar00preset: laravel disabled: - single_class_element_per_statement .github/workflows/run-tests.yml000066600000005230150515642310012636 0ustar00name: run-tests on: push: branches: - '*' tags: - '*' pull_request: branches: - '*' jobs: build: if: "!contains(github.event.head_commit.message, 'skip ci')" runs-on: ubuntu-latest strategy: fail-fast: false matrix: php: - '7.3' - '7.4' - '8.0' - '8.1' laravel: - 6.* - 7.* - 8.* 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' name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} --${{ matrix.prefer }} steps: - uses: actions/checkout@v1 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv coverage: pcov - name: Setup Redis uses: supercharge/redis-github-action@1.1.0 with: redis-version: 6 - uses: actions/cache@v1 name: Cache dependencies with: path: ~/.composer/cache/files key: composer-php-${{ matrix.php }}-${{ matrix.laravel }}-${{ matrix.prefer }}-${{ hashFiles('composer.json') }} - name: Install dependencies run: | composer require "laravel/framework:${{ matrix.laravel }}" "phpunit/phpunit:${{ matrix.phpunit }}" "orchestra/testbench-browser-kit:${{ matrix.testbench }}" "orchestra/database:${{ matrix.testbench }}" --no-interaction --no-update composer update --${{ matrix.prefer }} --prefer-dist --no-interaction --no-suggest - name: Run tests for Local run: | REPLICATION_MODE=local vendor/bin/phpunit --coverage-text --coverage-clover=coverage_local.xml - name: Run tests for Redis run: | REPLICATION_MODE=redis vendor/bin/phpunit --coverage-text --coverage-clover=coverage_redis.xml - uses: codecov/codecov-action@v1 with: fail_ci_if_error: false file: '*.xml' token: ${{ secrets.CODECOV_TOKEN }}composer.json000066600000004326150515642310007301 0ustar00{ "name": "beyondcode/laravel-websockets", "description": "An easy to use WebSocket server", "keywords": [ "beyondcode", "laravel-websockets" ], "homepage": "https://github.com/beyondcode/laravel-websockets", "license": "MIT", "authors": [ { "name": "Marcel Pociot", "email": "marcel@beyondco.de", "homepage": "https://beyondcode.de", "role": "Developer" }, { "name": "Freek Van der Herten", "email": "freek@spatie.be", "homepage": "https://spatie.be", "role": "Developer" } ], "require": { "php": "^7.2|^8.0", "ext-json": "*", "cboden/ratchet": "^0.4.1", "facade/ignition-contracts": "^1.0", "guzzlehttp/psr7": "^1.7|^2.0", "illuminate/broadcasting": "^6.0|^7.0|^8.0|^9.0|^10.0", "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0", "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0", "illuminate/routing": "^6.0|^7.0|^8.0|^9.0|^10.0", "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0", "pusher/pusher-php-server": "^3.0|^4.0|^5.0|^6.0|^7.0", "react/dns": "^1.1", "react/http": "^1.1", "symfony/http-kernel": "^4.0|^5.0|^6.0", "symfony/psr-http-message-bridge": "^1.1|^2.0" }, "require-dev": { "mockery/mockery": "^1.3.3", "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0", "phpunit/phpunit": "^8.0|^9.0|^10.0" }, "autoload": { "psr-4": { "BeyondCode\\LaravelWebSockets\\": "src" } }, "autoload-dev": { "psr-4": { "BeyondCode\\LaravelWebSockets\\Tests\\": "tests" } }, "scripts": { "test": "vendor/bin/phpunit", "test-coverage": "vendor/bin/phpunit --coverage-html coverage" }, "config": { "sort-packages": true }, "extra": { "laravel": { "providers": [ "BeyondCode\\LaravelWebSockets\\WebSocketsServiceProvider" ], "aliases": { "WebSocketRouter": "BeyondCode\\LaravelWebSockets\\Facades\\WebSocketRouter" } } } } docs/debugging/dashboard.md000066600000006230150515642310011707 0ustar00--- title: Debug Dashboard order: 2 --- # Debug Dashboard In addition to logging the events to the console, you can also use a real-time dashboard that shows you all incoming connections, events and disconnects the moment they happen on your WebSocket server. ![Debug Dashboard](/img/dashboard.jpg) ## Accessing the Dashboard The default location of the WebSocket dashboard is at `/laravel-websockets`. The routes get automatically registered. If you want to change the URL of the dashboard, you can configure it with the `path` setting in your `config/websockets.php` file. To access the debug dashboard, you can visit the dashboard URL of your Laravel project in the browser. Since your WebSocket server has support for multiple apps, you can select which app you want to connect to and inspect. By pressing the "Connect" button, you can establish the WebSocket connection and see all events taking place on your WebSocket server from there on in real-time. **Note:** Be sure to set the ``APP_URL`` env variable to match the current URL where your project is running to be sure the stats graph works properly. ## Protecting the Dashboard By default, access to the WebSocket dashboard is only allowed while your application environment is set to `local`. However, you can change this behavior by overriding the Laravel Gate being used. A good place for this is the `AuthServiceProvider` that ships with Laravel. ```php public function boot() { $this->registerPolicies(); Gate::define('viewWebSocketsDashboard', function ($user = null) { return in_array($user->email, [ // ]); }); } ``` ## Statistics This package allows you to record key metrics of your WebSocket server. The WebSocket server will store a snapshot of the current number of peak connections, the amount of received WebSocket messages and the amount of received API messages defined in a fixed interval. The default setting is to store a snapshot every 60 seconds. In addition to simply storing the statistic information in your database, you can also see the statistics as they happen in real-time on the debug dashboard. ![Real-Time Statistics](/img/statistics.gif) You can modify this interval by changing the `interval_in_seconds` setting in your config file. ## Cleanup old Statistics After using the WebSocket server for a while you will have recorded a lot of statistical data that you might no longer need. This package provides an artisan command `websockets:clean` to clean these statistic log entries. Running this command will result in the deletion of all recorded statistics that are older than the number of days specified in the `delete_statistics_older_than_days` setting of the config file. You can leverage Laravel's scheduler to run the clean up command now and then. ```php //app/Console/Kernel.php protected function schedule(Schedule $schedule) { $schedule->command('websockets:clean')->daily(); } ``` ## Event Creator The dashboard also comes with an easy-to-use event creator, that lets you manually send events to your channels. Simply enter the channel, the event name and provide a valid JSON payload to send it to all connected clients in the given channel. docs/debugging/_index.md000066600000000042150515642310011221 0ustar00--- title: Debugging order: 3 --- docs/debugging/console.md000066600000000414150515642310011420 0ustar00--- title: Console Logging order: 1 --- # Console Logging When you start the Laravel WebSocket server and your application is in debug mode, you will automatically see all incoming and outgoing WebSocket events in your terminal. ![Console Logging](/img/console.png)docs/basic-usage/starting.md000066600000005244150515642310012047 0ustar00--- title: Starting the WebSocket server order: 2 --- # Starting the WebSocket server Once you have configured your WebSocket apps and Pusher settings, you can start the Laravel WebSocket server by issuing the artisan command: ```bash php artisan websockets:serve ``` ## Using a different port The default port of the Laravel WebSocket server is `6001`. You may pass a different port to the command using the `--port` option. ```bash php artisan websockets:serve --port=3030 ``` This will start listening on port `3030`. ## Restricting the listening host By default, the Laravel WebSocket server will listen on `0.0.0.0` and will allow incoming connections from all networks. If you want to restrict this, you can start the server with a `--host` option, followed by an IP. For example, by using `127.0.0.1`, you will only allow WebSocket connections from localhost. ```bash php artisan websockets:serve --host=127.0.0.1 ``` ## Keeping the socket server running with supervisord The `websockets:serve` daemon needs to always be running in order to accept connections. This is a prime use case for `supervisor`, a task runner on Linux. First, make sure `supervisor` is installed. ```bash # On Debian / Ubuntu apt install supervisor # On Red Hat / CentOS yum install supervisor systemctl enable supervisord ``` Once installed, add a new process that `supervisor` needs to keep running. You place your configurations in the `/etc/supervisor/conf.d` (Debian/Ubuntu) or `/etc/supervisord.d` (Red Hat/CentOS) directory. Within that directory, create a new file called `websockets.conf`. ```bash [program:websockets] command=/usr/bin/php /home/laravel-echo/laravel-websockets/artisan websockets:serve numprocs=1 autostart=true autorestart=true user=laravel-echo ``` Once created, instruct `supervisor` to reload its configuration files (without impacting the already running `supervisor` jobs). ```bash supervisorctl update supervisorctl start websockets ``` Your echo server should now be running (you can verify this with `supervisorctl status`). If it were to crash, `supervisor` will automatically restart it. Please note that, by default, `supervisor` will force a maximum number of open files onto all the processes that it manages. This is configured by the `minfds` parameter in `supervisord.conf`. If you want to increase the maximum number of open files, you may do so in `/etc/supervisor/supervisord.conf` (Debian/Ubuntu) or `/etc/supervisord.conf` (Red Hat/CentOS): ``` [supervisord] minfds=10240; (min. avail startup file descriptors;default 1024) ``` After changing this setting, you'll need to restart the supervisor process (which in turn will restart all your processes that it manages). docs/basic-usage/sail.md000066600000001075150515642310011142 0ustar00--- title: Laravel Sail order: 4 --- # Run in Laravel Sail To be able to use Laravel Websockets in Sail, you should just forward the port: ```yaml # For more information: https://laravel.com/docs/sail version: '3' services: laravel.test: build: context: ./vendor/laravel/sail/runtimes/8.0 dockerfile: Dockerfile args: WWWGROUP: '${WWWGROUP}' image: sail-8.0/app ports: - '${APP_PORT:-80}:80' - '${LARAVEL_WEBSOCKETS_PORT:-6001}:${LARAVEL_WEBSOCKETS_PORT:-6001}' ``` docs/basic-usage/pusher.md000066600000012757150515642310011531 0ustar00--- title: Pusher Replacement order: 1 --- # Pusher Replacement The easiest way to get started with Laravel WebSockets is by using it as a [Pusher](https://pusher.com) replacement. The integrated WebSocket and HTTP Server has complete feature parity with the Pusher WebSocket and HTTP API. In addition to that, this package also ships with an easy to use debugging dashboard to see all incoming and outgoing WebSocket requests. ## Requirements To make use of the Laravel WebSockets package in combination with Pusher, you first need to install the official Pusher PHP SDK. If you are not yet familiar with the concept of Broadcasting in Laravel, please take a look at the [Laravel documentation](https://laravel.com/docs/6.0/broadcasting). ```bash composer require pusher/pusher-php-server "~3.0" ``` Next, you should make sure to use Pusher as your broadcasting driver. This can be achieved by setting the `BROADCAST_DRIVER` environment variable in your `.env` file: ``` BROADCAST_DRIVER=pusher ``` ## Pusher Configuration When broadcasting events from your Laravel application to your WebSocket server, the default behavior is to send the event information to the official Pusher server. But since the Laravel WebSockets package comes with its own Pusher API implementation, we need to tell Laravel to send the events to our own server. To do this, you should add the `host` and `port` configuration key to your `config/broadcasting.php` and add it to the `pusher` section. The default port of the Laravel WebSocket server is 6001. ```php 'pusher' => [ 'driver' => 'pusher', 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'app_id' => env('PUSHER_APP_ID'), 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), 'encrypted' => true, 'host' => '127.0.0.1', 'port' => 6001, 'scheme' => 'http' ], ], ``` ## Configuring WebSocket Apps The Laravel WebSocket Pusher replacement server comes with multi-tenancy support out of the box. This means that you could host it independently from your current Laravel application and serve multiple WebSocket applications with one server. To make the move from an existing Pusher setup to this package as easy as possible, the default app simply uses your existing Pusher configuration. ::: warning Make sure to use the same app id, key and secret as in your broadcasting configuration section. Otherwise broadcasting events from Laravel will not work. ::: ::: tip When using Laravel WebSockets as a Pusher replacement without having used Pusher before, it does not matter what you set as your `PUSHER_` variables. Just make sure they are unique for each project. ::: You may add additional apps in your `config/websockets.php` file. ```php 'apps' => [ [ 'id' => env('PUSHER_APP_ID'), 'name' => env('APP_NAME'), 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'enable_client_messages' => false, 'enable_statistics' => true, ], ], ``` ### Client Messages For each app in your configuration file, you can define if this specific app should support a client-to-client messages. Usually all WebSocket messages go through your Laravel application before they will be broadcasted to other users. But sometimes you may want to enable a direct client-to-client communication instead of sending the events over the server. For example, a "typing" event in a chat application. It is important that you apply additional care when using client messages, since these originate from other users, and could be subject to tampering by a malicious user of your site. To enable or disable client messages, you can modify the `enable_client_messages` setting. The default value is `false`. ### Statistics The Laravel WebSockets package comes with an out-of-the-box statistic solution that will give you key insights into the current status of your WebSocket server. To enable or disable the statistics for one of your apps, you can modify the `enable_statistics` setting. The default value is `true`. ## Usage with Laravel Echo The Laravel WebSockets package integrates nicely into [Laravel Echo](https://laravel.com/docs/6.0/broadcasting#receiving-broadcasts) to integrate into your frontend application and receive broadcasted events. If you are new to Laravel Echo, be sure to take a look at the [official documentation](https://laravel.com/docs/6.0/broadcasting#receiving-broadcasts). To make Laravel Echo work with Laravel WebSockets, you need to make some minor configuration changes when working with Laravel Echo. Add the `wsHost` and `wsPort` parameters and point them to your Laravel WebSocket server host and port. By default, the Pusher JavaScript client tries to send statistic information - you should disable this using the `disableStats` option. ::: tip When using Laravel WebSockets in combination with a custom SSL certificate, be sure to use the `encrypted` option and set it to `true`. ::: ```js import Echo from "laravel-echo" window.Pusher = require('pusher-js'); window.Echo = new Echo({ broadcaster: 'pusher', key: 'your-pusher-key', wsHost: window.location.hostname, wsPort: 6001, forceTLS: false, disableStats: true, }); ``` Now you can use all Laravel Echo features in combination with Laravel WebSockets, such as [Presence Channels](https://laravel.com/docs/6.0/broadcasting#presence-channels), [Notifications](https://laravel.com/docs/6.0/broadcasting#notifications) and [Client Events](https://laravel.com/docs/6.0/broadcasting#client-events). docs/basic-usage/ssl.md000066600000022661150515642310011017 0ustar00--- title: SSL Support order: 3 --- # SSL Support Since most of the web's traffic is going through HTTPS, it's also crucial to secure your WebSocket server. Luckily, adding SSL support to this package is really simple. ## Configuration The SSL configuration takes place in your `config/websockets.php` file. The default configuration has a SSL section that looks like this: ```php 'ssl' => [ /* * Path to local certificate file on filesystem. It must be a PEM encoded file which * contains your certificate and private key. It can optionally contain the * certificate chain of issuers. The private key also may be contained * in a separate file specified by local_pk. */ 'local_cert' => null, /* * Path to local private key file on filesystem in case of separate files for * certificate (local_cert) and private key. */ 'local_pk' => null, /* * Passphrase with which your local_cert file was encoded. */ 'passphrase' => null ], ``` But this is only a subset of all the available configuration options. This packages makes use of the official PHP [SSL context options](http://php.net/manual/en/context.ssl.php). So if you find yourself in the need of adding additional configuration settings, take a look at the PHP documentation and simply add the configuration parameters that you need. After setting up your SSL settings, you can simply (re)start your WebSocket server using: ```bash php artisan websockets:serve ``` ## Client configuration When your SSL settings are in place and working, you still need to tell Laravel Echo that it should make use of it. You can do this by specifying the `forceTLS` property in your JavaScript file, like this: ```js import Echo from "laravel-echo" window.Pusher = require('pusher-js'); window.Echo = new Echo({ broadcaster: 'pusher', key: 'your-pusher-key', wsHost: window.location.hostname, wsPort: 6001, wssPort: 6001, disableStats: true, forceTLS: true }); ``` ## Server configuration When broadcasting events from your Laravel application to the WebSocket server, you also need to tell Laravel to make use of HTTPS instead of HTTP. You can do this by setting the `scheme` option in your `config/broadcasting.php` file to `https`: ```php 'pusher' => [ 'driver' => 'pusher', 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'app_id' => env('PUSHER_APP_ID'), 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), 'host' => '127.0.0.1', 'port' => 6001, 'scheme' => 'https' ], ], ``` Since the SSL configuration can vary quite a lot, depending on your setup, let's take a look at the most common approaches. ## Usage with Laravel Valet Laravel Valet uses self-signed SSL certificates locally. To use self-signed certificates with Laravel WebSockets, here's how the SSL configuration section in your `config/websockets.php` file should look like. ::: tip Make sure that you replace `YOUR-USERNAME` with your Mac username and `VALET-SITE.TLD` with the host of the Valet site that you're working on right now. For example `laravel-websockets-demo.test`. ::: ```php 'ssl' => [ /* * Path to local certificate file on filesystem. It must be a PEM encoded file which * contains your certificate and private key. It can optionally contain the * certificate chain of issuers. The private key also may be contained * in a separate file specified by local_pk. */ 'local_cert' => '/Users/YOUR-USERNAME/.config/valet/Certificates/VALET-SITE.TLD.crt', /* * Path to local private key file on filesystem in case of separate files for * certificate (local_cert) and private key. */ 'local_pk' => '/Users/YOUR-USERNAME/.config/valet/Certificates/VALET-SITE.TLD.key', /* * Passphrase with which your local_cert file was encoded. */ 'passphrase' => null, 'verify_peer' => false, ], ``` Next, you need to adjust the `config/broadcasting.php` file to make use of a secure connection when broadcasting messages from Laravel to the WebSocket server. You also need to disable SSL verification. ```php 'pusher' => [ 'driver' => 'pusher', 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'app_id' => env('PUSHER_APP_ID'), 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), 'host' => '127.0.0.1', 'port' => 6001, 'scheme' => 'https', 'curl_options' => [ CURLOPT_SSL_VERIFYHOST => 0, CURLOPT_SSL_VERIFYPEER => 0, ] ], ], ``` Last but not least, you still need to configure Laravel Echo to also use WSS on port 6001. ```js window.Echo = new Echo({ broadcaster: 'pusher', key: 'your-pusher-key', wsHost: window.location.hostname, wsPort: 6001, wssPort: 6001, disableStats: true, }); ``` ## Usage with a reverse proxy (like Nginx) Alternatively, you can also use a proxy service - like Nginx, HAProxy or Caddy - to handle the SSL configurations and proxy all requests in plain HTTP to your echo server. A basic Nginx configuration would look like this, but you might want to tweak the SSL parameters to your liking. ``` server { listen 443 ssl; listen [::]:443 ssl; server_name socket.yourapp.tld; # Start the SSL configurations ssl on; ssl_certificate /etc/letsencrypt/live/socket.yourapp.tld/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/socket.yourapp.tld/privkey.pem; location / { proxy_pass http://127.0.0.1:6001; proxy_read_timeout 60; proxy_connect_timeout 60; proxy_redirect off; # Allow the use of websockets proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } } ``` You can now talk HTTPS to `socket.yourapp.tld`. You would configure your `config/broadcasting.php` like the example above, treating your socket server as an `https` endpoint. ### Same location for websockets and web contents To have the websockets be served at the same location and port as your other web content, Nginx can be taught to map incoming requests based on their type to special sub-locations. ``` map $http_upgrade $type { default "web"; websocket "ws"; } server { # Your default configuration comes here... location / { try_files /nonexistent @$type; } location @web { try_files $uri $uri/ /index.php?$query_string; } location @ws { proxy_pass http://127.0.0.1:6001; proxy_set_header Host $host; proxy_read_timeout 60; proxy_connect_timeout 60; proxy_redirect off; # Allow the use of websockets proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } } ``` This configuration is useful if you do not want to open multiple ports or you are restricted to which ports are already opened on your server. Alternatively, a second Nginx location can be used on the server-side, while the Pusher configuration [`wsPath`](https://github.com/pusher/pusher-js#wspath) can be used on the client-side (_note: `"pusher-js": ">=4.2.2"` is required for this configuration option_). ``` server { # Your default configuration comes here... location /ws { proxy_pass http://127.0.0.1:6001; proxy_set_header Host $host; proxy_read_timeout 60; proxy_connect_timeout 60; proxy_redirect off; # Allow the use of websockets proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } } ``` ### Nginx worker connections Note that you might need to increase the amount of `worker_connections` in Nginx. Your WebSocket connections will now be sent to Nginx, which in turn will send those along to the websocket server. By default, that will have a sane limit of 1024 connections. If you are expecting more concurrent connections to your WebSockets, you can increase this in your global `nginx.conf`. ``` events { worker_connections 1024; } ``` You know you've reached this limit of your Nginx error logs contain similar messages to these: ``` [alert] 1024 worker_connections are not enough while connecting to upstream ``` Remember to restart your Nginx after you've modified the `worker_connections`. ### Example using Caddy [Caddy](https://caddyserver.com) can also be used to automatically obtain a TLS certificate from Let's Encrypt and terminate TLS before proxying to your echo server. An example configuration would look like this: ``` socket.yourapp.tld { rewrite / { if {>Connection} has Upgrade if {>Upgrade} is websocket to /websocket-proxy/{path}?{query} } proxy /websocket-proxy 127.0.0.1:6001 { without /special-websocket-url transparent websocket } tls youremail.com } ``` Note the `to /websocket-proxy`, this is a dummy path to allow the `proxy` directive to only proxy on websocket connections. This should be a path that will never be used by your application's routing. Also, note that you should change `127.0.0.1` to the hostname of your websocket server. For example, if you're running in a Docker environment, this might be the container name of your websocket server. docs/basic-usage/_index.md000066600000000044150515642310011453 0ustar00--- title: Basic Usage order: 2 --- docs/faq/scaling.md000066600000001630150515642310010213 0ustar00--- title: ... but does it scale? order: 2 --- # ... but does it scale? Of course, this is not a question with an easy answer as your mileage may vary. But with the appropriate server-side configuration your WebSocket server can easily hold a **lot** of concurrent connections. This is an example benchmark that was done on the smallest Digital Ocean droplet, that also had a couple of other Laravel projects running. On this specific server, the maximum number of **concurrent** connections ended up being ~15,000. ![Benchmark](/img/simultaneous_users.png) Here is another benchmark that was run on a 2GB Digital Ocean droplet with 2 CPUs. The maximum number of **concurrent** connections on this server setup is nearly 60,000. ![Benchmark](/img/simultaneous_users_2gb.png) Make sure to take a look at the [Deployment Tips](/docs/laravel-websockets/faq/deploying) to find out how to improve your specific setup. docs/faq/_index.md000066600000000034150515642310010036 0ustar00--- title: FAQ order: 5 --- docs/faq/deploying.md000066600000003667150515642310010601 0ustar00--- title: Deploying order: 1 --- # Deploying When your application is ready to get deployed, here are some tips to improve your WebSocket server. ### Open Connection Limit On Unix systems, every user that connects to your WebSocket server is represented as a file somewhere on the system. As a security measurement of every Unix based OS, the number of "file descriptors" an application may have open at a time is limited - most of the time to a default value of 1024 - which would result in a maximum number of 1024 concurrent users on your WebSocket server. In addition to the OS restrictions, this package makes use of an event loop called "stream_select", which has a hard limit of 1024. #### Increasing the maximum number of file descriptors The operating system limit of open "file descriptors" can be increased using the `ulimit` command. The `-n` option modifies the number of open file descriptors. ```bash ulimit -n 10000 ``` The `ulimit` command only **temporarily** increases the maximum number of open file descriptors. To permanently modify this value, you can edit it in your operating system `limits.conf` file. You are best to do so by creating a file in the `limits.d` directory. This will work for both Red Hat & Ubuntu derivatives. ```bash $ cat /etc/security/limits.d/laravel-echo.conf laravel-echo soft nofile 10000 ``` The above example assumes you will run your echo server as the `laravel-echo` user, you are free to change that to your liking. #### Changing the event loop To make use of a different event loop, that does not have a hard limit of 1024 concurrent connections, you can either install the `ev` or `event` PECL extension using: ```bash sudo pecl install ev # or sudo pecl install event ``` #### Deploying on Laravel Forge If your are using [Laravel Forge](https://forge.laravel.com/) for the deployment [this article by Alex Bouma](https://alex.bouma.dev/installing-laravel-websockets-on-forge) might help you out. docs/_index.md000066600000000143150515642310007270 0ustar00--- packageName: Laravel Websockets githubUrl: https://github.com/beyondcode/laravel-websockets ---docs/advanced-usage/app-providers.md000066600000006410150515642310013467 0ustar00# Custom App Providers With the multi-tenancy support of Laravel WebSockets, the default way of storing and retrieving the apps is by using the `websockets.php` config file. Depending on your setup, you might have your app configuration stored elsewhere and having to keep the configuration in sync with your app storage can be tedious. To simplify this, you can create your own `AppProvider` class that will take care of retrieving the WebSocket credentials for a specific WebSocket application. > Make sure that you do **not** perform any IO blocking tasks in your `AppProvider`, as they will interfere with the asynchronous WebSocket execution. In order to create your custom `AppProvider`, create a class that implements the `BeyondCode\LaravelWebSockets\AppProviders\AppProvider` interface. This is what it looks like: ```php interface AppProvider { /** @return array[BeyondCode\LaravelWebSockets\AppProviders\App] */ public function all(): array; /** @return BeyondCode\LaravelWebSockets\AppProviders\App */ public function findById($appId): ?App; /** @return BeyondCode\LaravelWebSockets\AppProviders\App */ public function findByKey(string $appKey): ?App; /** @return BeyondCode\LaravelWebSockets\AppProviders\App */ public function findBySecret(string $appSecret): ?App; } ``` The following is an example AppProvider that utilizes an Eloquent model: ```php namespace App\Providers; use App\Application; use BeyondCode\LaravelWebSockets\Apps\App; use BeyondCode\LaravelWebSockets\Apps\AppProvider; class MyCustomAppProvider implements AppProvider { public function all() : array { return Application::all() ->map(function($app) { return $this->instanciate($app->toArray()); }) ->toArray(); } public function findById($appId) : ? App { return $this->instanciate(Application::findById($appId)->toArray()); } public function findByKey(string $appKey) : ? App { return $this->instanciate(Application::findByKey($appKey)->toArray()); } public function findBySecret(string $appSecret) : ? App { return $this->instanciate(Application::findBySecret($appSecret)->toArray()); } protected function instanciate(?array $appAttributes) : ? App { if (!$appAttributes) { return null; } $app = new App( $appAttributes['id'], $appAttributes['key'], $appAttributes['secret'] ); if (isset($appAttributes['name'])) { $app->setName($appAttributes['name']); } if (isset($appAttributes['host'])) { $app->setHost($appAttributes['host']); } $app ->enableClientMessages($appAttributes['enable_client_messages']) ->enableStatistics($appAttributes['enable_statistics']); return $app; } } ``` Once you have implemented your own AppProvider, you need to set it in the `websockets.php` configuration file: ```php /** * This class is responsible for finding the apps. The default provider * will use the apps defined in this config file. * * You can create a custom provider by implementing the * `AppProvider` interface. */ 'app_provider' => MyCustomAppProvider::class, ``` docs/advanced-usage/_index.md000066600000000047150515642310012142 0ustar00--- title: Advanced Usage order: 4 --- docs/advanced-usage/custom-websocket-handlers.md000066600000004453150515642310015775 0ustar00# Custom WebSocket Handlers While this package's main purpose is to make the usage of either the Pusher JavaScript client or Laravel Echo as easy as possible, you are not limited to the Pusher protocol at all. There might be situations where all you need is a simple, bare-bone, WebSocket server where you want to have full control over the incoming payload and what you want to do with it - without having "channels" in the way. You can easily create your own custom WebSocketHandler class. All you need to do is implement Ratchets `Ratchet\WebSocket\MessageComponentInterface`. Once implemented, you will have a class that looks something like this: ```php namespace App; use Ratchet\ConnectionInterface; use Ratchet\RFC6455\Messaging\MessageInterface; use Ratchet\WebSocket\MessageComponentInterface; class MyCustomWebSocketHandler implements MessageComponentInterface { public function onOpen(ConnectionInterface $connection) { // TODO: Implement onOpen() method. } public function onClose(ConnectionInterface $connection) { // TODO: Implement onClose() method. } public function onError(ConnectionInterface $connection, \Exception $e) { // TODO: Implement onError() method. } public function onMessage(ConnectionInterface $connection, MessageInterface $msg) { // TODO: Implement onMessage() method. } } ``` In the class itself you have full control over all the lifecycle events of your WebSocket connections and can intercept the incoming messages and react to them. The only part missing is, that you will need to tell our WebSocket server to load this handler at a specific route endpoint. This can be achieved using the `WebSocketsRouter` facade. This class takes care of registering the routes with the actual webSocket server. You can use the `webSocket` method to define a custom WebSocket endpoint. The method needs two arguments: the path where the WebSocket handled should be available and the fully qualified classname of the WebSocket handler class. This could, for example, be done inside your `routes/web.php` file. ```php WebSocketsRouter::webSocket('/my-websocket', \App\MyCustomWebSocketHandler::class); ``` Once you've added the custom WebSocket route, be sure to restart our WebSocket server for the changes to take place.docs/getting-started/introduction.md000066600000001531150515642310013652 0ustar00--- title: Introduction order: 1 --- # Laravel WebSockets 🛰 WebSockets for Laravel. Done right. Laravel WebSockets is a package for Laravel 5.7 and up that will get your application started with WebSockets in no-time! It has a drop-in Pusher API replacement, has a debug dashboard, realtime statistics and even allows you to create custom WebSocket controllers. Once installed, you can start it with one simple command: ```php php artisan websockets:serve ``` --- If you want to know how all of it works under the hood, we wrote an in-depth [blogpost](https://murze.be/introducing-laravel-websockets-an-easy-to-use-websocket-server-implemented-in-php) about it. To help you get started, you can also take a look at the [demo repository](https://github.com/beyondcode/laravel-websockets-demo), that implements a basic Chat built with this package.docs/getting-started/questions-issues.md000066600000000742150515642310014477 0ustar00--- title: Questions and issues order: 3 --- # Questions and issues Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving laravel-websockets? Feel free to create an issue on [GitHub](https://github.com/beyondcode/laravel-websockets/issues), we'll try to address it as soon as possible. If you've found a bug regarding security please mail [marcel@beyondco.de](mailto:marcel@beyondco.de) instead of using the issue tracker.docs/getting-started/installation.md000066600000012764150515642310013644 0ustar00--- title: Installation order: 2 --- # Installation Laravel WebSockets can be installed via composer: ```bash composer require beyondcode/laravel-websockets ``` The package will automatically register a service provider. This package comes with a migration to store statistic information while running your WebSocket server. You can publish the migration file using: ```bash php artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsServiceProvider" --tag="migrations" ``` Run the migrations with: ```bash php artisan migrate ``` Next, you need to publish the WebSocket configuration file: ```bash php artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsServiceProvider" --tag="config" ``` This is the default content of the config file that will be published as `config/websockets.php`: ```php return [ /* * Set a custom dashboard configuration */ 'dashboard' => [ 'port' => env('LARAVEL_WEBSOCKETS_PORT', 6001), ], /* * This package comes with multi tenancy out of the box. Here you can * configure the different apps that can use the webSockets server. * * Optionally you specify capacity so you can limit the maximum * concurrent connections for a specific app. * * Optionally you can disable client events so clients cannot send * messages to each other via the webSockets. */ 'apps' => [ [ 'id' => env('PUSHER_APP_ID'), 'name' => env('APP_NAME'), 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'path' => env('PUSHER_APP_PATH'), 'capacity' => null, 'enable_client_messages' => false, 'enable_statistics' => true, ], ], /* * This class is responsible for finding the apps. The default provider * will use the apps defined in this config file. * * You can create a custom provider by implementing the * `AppProvider` interface. */ 'app_provider' => BeyondCode\LaravelWebSockets\Apps\ConfigAppProvider::class, /* * This array contains the hosts of which you want to allow incoming requests. * Leave this empty if you want to accept requests from all hosts. */ 'allowed_origins' => [ // ], /* * The maximum request size in kilobytes that is allowed for an incoming WebSocket request. */ 'max_request_size_in_kb' => 250, /* * This path will be used to register the necessary routes for the package. */ 'path' => 'laravel-websockets', /* * Dashboard Routes Middleware * * These middleware will be assigned to every dashboard route, giving you * the chance to add your own middleware to this list or change any of * the existing middleware. Or, you can simply stick with this list. */ 'middleware' => [ 'web', Authorize::class, ], 'statistics' => [ /* * This model will be used to store the statistics of the WebSocketsServer. * The only requirement is that the model should extend * `WebSocketsStatisticsEntry` provided by this package. */ 'model' => \BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry::class, /** * The Statistics Logger will, by default, handle the incoming statistics, store them * and then release them into the database on each interval defined below. */ 'logger' => BeyondCode\LaravelWebSockets\Statistics\Logger\HttpStatisticsLogger::class, /* * Here you can specify the interval in seconds at which statistics should be logged. */ 'interval_in_seconds' => 60, /* * When the clean-command is executed, all recorded statistics older than * the number of days specified here will be deleted. */ 'delete_statistics_older_than_days' => 60, /* * Use an DNS resolver to make the requests to the statistics logger * default is to resolve everything to 127.0.0.1. */ 'perform_dns_lookup' => false, ], /* * Define the optional SSL context for your WebSocket connections. * You can see all available options at: http://php.net/manual/en/context.ssl.php */ 'ssl' => [ /* * Path to local certificate file on filesystem. It must be a PEM encoded file which * contains your certificate and private key. It can optionally contain the * certificate chain of issuers. The private key also may be contained * in a separate file specified by local_pk. */ 'local_cert' => env('LARAVEL_WEBSOCKETS_SSL_LOCAL_CERT', null), /* * Path to local private key file on filesystem in case of separate files for * certificate (local_cert) and private key. */ 'local_pk' => env('LARAVEL_WEBSOCKETS_SSL_LOCAL_PK', null), /* * Passphrase for your local_cert file. */ 'passphrase' => env('LARAVEL_WEBSOCKETS_SSL_PASSPHRASE', null), ], /* * Channel Manager * This class handles how channel persistence is handled. * By default, persistence is stored in an array by the running webserver. * The only requirement is that the class should implement * `ChannelManager` interface provided by this package. */ 'channel_manager' => \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\ArrayChannelManager::class, ]; ``` docs/getting-started/_index.md000066600000000050150515642310012372 0ustar00--- title: Getting Started order: 1 --- resources/views/dashboard.blade.php000066600000024213150515642310013431 0ustar00 WebSockets Dashboard

Realtime Statistics

Event Creator

Events

Type Socket Details Time
@{{ log.type }} @{{ log.socketId }} @{{ log.details }} @{{ log.time }}
CONTRIBUTING.md000066600000005634150515642310007013 0ustar00# Contributing Contributions are **welcome** and will be fully **credited**. Please read and understand the contribution guide before creating an issue or pull request. ## Etiquette This project is open source, and as such, the maintainers give their free time to build and maintain the source code held within. They make the code freely available in the hope that it will be of use to other developers. It would be extremely unfair for them to suffer abuse or anger for their hard work. Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the world that developers are civilized and selfless people. It's the duty of the maintainer to ensure that all submissions to the project are of sufficient quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. ## Viability When requesting or submitting new features, first consider whether it might be useful to others. Open source projects are used by many developers, who may have entirely different needs to your own. Think about whether or not your feature is likely to be used by other users of the project. ## Procedure Before filing an issue: - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. - Check to make sure your feature suggestion isn't already present within the project. - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. - Check the pull requests tab to ensure that the feature isn't already in progress. Before submitting a pull request: - Check the codebase to ensure that your feature doesn't already exist. - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. ## Requirements If the project maintainer has any additional requirements, you will find them listed here. - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). - **Add tests!** - Your patch won't be accepted if it doesn't have tests. - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. **Happy coding**!