tipo: modificación del cpanel y gitignore de la carpeta vendor

This commit is contained in:
2026-07-07 11:35:09 -06:00
parent a0c91dc567
commit 676ef0d506
8510 changed files with 1132803 additions and 4 deletions
@@ -0,0 +1,47 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Channels;
use Laravel\Reverb\Contracts\Connection;
class CacheChannel extends Channel
{
/**
* Data from last event triggered.
*/
protected ?array $payload = null;
/**
* Send a message to all connections subscribed to the channel.
*/
public function broadcast(array $payload, ?Connection $except = null): void
{
$this->payload = $payload;
parent::broadcast($payload, $except);
}
/**
* Broadcast a message triggered from an internal source.
*/
public function broadcastInternally(array $payload, ?Connection $except = null): void
{
parent::broadcast($payload, $except);
}
/**
* Determine if the channel has a cached payload.
*/
public function hasCachedPayload(): bool
{
return $this->payload !== null;
}
/**
* Get the cached payload.
*/
public function cachedPayload(): ?array
{
return $this->payload;
}
}
@@ -0,0 +1,147 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Channels;
use Laravel\Reverb\Contracts\Connection;
use Laravel\Reverb\Loggers\Log;
use Laravel\Reverb\Protocols\Pusher\Concerns\SerializesChannels;
use Laravel\Reverb\Protocols\Pusher\Contracts\ChannelConnectionManager;
use Laravel\Reverb\Protocols\Pusher\Contracts\ChannelManager;
class Channel
{
use SerializesChannels;
/**
* The channel connections.
*
* @var \Laravel\Reverb\Contracts\ChannelConnectionManager
*/
protected $connections;
/**
* Create a new channel instance.
*/
public function __construct(protected string $name)
{
$this->connections = app(ChannelConnectionManager::class)->for($this->name);
}
/**
* Get the channel name.
*/
public function name(): string
{
return $this->name;
}
/**
* Get all connections for the channel.
*
* @return array<string, \Laravel\Reverb\Protocols\Pusher\Channels\ChannelConnection>
*/
public function connections(): array
{
return $this->connections->all();
}
/**
* Find a connection.
*/
public function find(Connection $connection): ?ChannelConnection
{
return $this->connections->find($connection);
}
/**
* Find a connection by its ID.
*/
public function findById(string $id): ?ChannelConnection
{
return $this->connections->findById($id);
}
/**
* Subscribe to the given channel.
*/
public function subscribe(Connection $connection, ?string $auth = null, ?string $data = null): void
{
$this->connections->add($connection, $data ? json_decode($data, associative: true, flags: JSON_THROW_ON_ERROR) : []);
}
/**
* Unsubscribe from the given channel.
*/
public function unsubscribe(Connection $connection): void
{
$this->connections->remove($connection);
if ($this->connections->isEmpty()) {
app(ChannelManager::class)->for($connection->app())->remove($this);
}
}
/**
* Determine if the connection is subscribed to the channel.
*/
public function subscribed(Connection $connection): bool
{
return $this->connections->find($connection) !== null;
}
/**
* Send a message to all connections subscribed to the channel.
*/
public function broadcast(array $payload, ?Connection $except = null): void
{
if ($except === null) {
$this->broadcastToAll($payload);
return;
}
$message = json_encode($payload);
Log::info('Broadcasting To', $this->name());
Log::message($message);
foreach ($this->connections() as $connection) {
if ($except->id() === $connection->id()) {
continue;
}
$connection->send($message);
}
}
/**
* Send a broadcast to all connections.
*/
public function broadcastToAll(array $payload): void
{
$message = json_encode($payload);
Log::info('Broadcasting To', $this->name());
Log::message($message);
foreach ($this->connections() as $connection) {
$connection->send($message);
}
}
/**
* Broadcast a message triggered from an internal source.
*/
public function broadcastInternally(array $payload, ?Connection $except = null): void
{
$this->broadcast($payload, $except);
}
/**
* Get the data associated with the channel.
*/
public function data(): array
{
return [];
}
}
@@ -0,0 +1,23 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Channels;
use Illuminate\Support\Str;
class ChannelBroker
{
/**
* Return the relevant channel instance.
*/
public static function create(string $name): Channel
{
return match (true) {
Str::startsWith($name, 'private-cache-') => new PrivateCacheChannel($name),
Str::startsWith($name, 'presence-cache-') => new PresenceCacheChannel($name),
Str::startsWith($name, 'cache') => new CacheChannel($name),
Str::startsWith($name, 'private') => new PrivateChannel($name),
Str::startsWith($name, 'presence') => new PresenceChannel($name),
default => new Channel($name),
};
}
}
@@ -0,0 +1,49 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Channels;
use Illuminate\Support\Arr;
use Laravel\Reverb\Contracts\Connection;
class ChannelConnection
{
/**
* Create a new channel connection instance.
*/
public function __construct(protected Connection $connection, protected array $data = [])
{
//
}
/**
* Get the underlying connection.
*/
public function connection(): Connection
{
return $this->connection;
}
/**
* Get the connection data.
*/
public function data(?string $key = null): mixed
{
return $key ? Arr::get($this->data, $key) : $this->data;
}
/**
* Send a message to the connection.
*/
public function send(string $message): void
{
$this->connection->send($message);
}
/**
* Proxy the given method to the underlying connection.
*/
public function __call(string $method, array $parameters): mixed
{
return $this->connection->{$method}(...$parameters);
}
}
@@ -0,0 +1,104 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Channels\Concerns;
use Laravel\Reverb\Contracts\Connection;
trait InteractsWithPresenceChannels
{
use InteractsWithPrivateChannels;
/**
* Subscribe to the given channel.
*/
public function subscribe(Connection $connection, ?string $auth = null, ?string $data = null): void
{
$this->verify($connection, $auth, $data);
$userData = $data ? json_decode($data, associative: true, flags: JSON_THROW_ON_ERROR) : [];
if ($this->userIsSubscribed($userData['user_id'] ?? null)) {
parent::subscribe($connection, $auth, $data);
return;
}
parent::subscribe($connection, $auth, $data);
parent::broadcastInternally(
[
'event' => 'pusher_internal:member_added',
'data' => json_encode((object) $userData),
'channel' => $this->name(),
],
$connection
);
}
/**
* Unsubscribe from the given channel.
*/
public function unsubscribe(Connection $connection): void
{
$subscription = $this->connections->find($connection);
parent::unsubscribe($connection);
if (
! $subscription ||
! $subscription->data('user_id') ||
$this->userIsSubscribed($subscription->data('user_id'))
) {
return;
}
parent::broadcast(
[
'event' => 'pusher_internal:member_removed',
'data' => json_encode(['user_id' => $subscription->data('user_id')]),
'channel' => $this->name(),
],
$connection
);
}
/**
* Get the data associated with the channel.
*/
public function data(): array
{
$connections = collect($this->connections->all())
->map(fn ($connection) => $connection->data())
->unique('user_id');
if ($connections->contains(fn ($connection) => ! isset($connection['user_id']))) {
return [
'presence' => [
'count' => 0,
'ids' => [],
'hash' => [],
],
];
}
return [
'presence' => [
'count' => $connections->count() ?? 0,
'ids' => $connections->map(fn ($connection) => $connection['user_id'])->values()->all(),
'hash' => $connections->keyBy('user_id')->map->user_info->toArray(),
],
];
}
/**
* Determine if the given user is subscribed to the channel.
*/
protected function userIsSubscribed(?string $userId): bool
{
if (! $userId) {
return false;
}
return collect($this->connections->all())->map(fn ($connection) => (string) $connection->data('user_id'))->contains($userId);
}
}
@@ -0,0 +1,45 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Channels\Concerns;
use Illuminate\Support\Str;
use Laravel\Reverb\Contracts\Connection;
use Laravel\Reverb\Protocols\Pusher\Exceptions\ConnectionUnauthorized;
trait InteractsWithPrivateChannels
{
/**
* Subscribe to the given channel.
*/
public function subscribe(Connection $connection, ?string $auth = null, ?string $data = null): void
{
$this->verify($connection, $auth, $data);
parent::subscribe($connection, $auth, $data);
}
/**
* Determine whether the given authentication token is valid.
*/
protected function verify(Connection $connection, ?string $auth = null, ?string $data = null): bool
{
$signature = "{$connection->id()}:{$this->name()}";
if ($data) {
$signature .= ":{$data}";
}
if (! hash_equals(
hash_hmac(
'sha256',
$signature,
$connection->app()->secret(),
),
Str::after($auth, ':')
)) {
throw new ConnectionUnauthorized;
}
return true;
}
}
@@ -0,0 +1,10 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Channels;
use Laravel\Reverb\Protocols\Pusher\Channels\Concerns\InteractsWithPresenceChannels;
class PresenceCacheChannel extends CacheChannel
{
use InteractsWithPresenceChannels;
}
@@ -0,0 +1,10 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Channels;
use Laravel\Reverb\Protocols\Pusher\Channels\Concerns\InteractsWithPresenceChannels;
class PresenceChannel extends PrivateChannel
{
use InteractsWithPresenceChannels;
}
@@ -0,0 +1,10 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Channels;
use Laravel\Reverb\Protocols\Pusher\Channels\Concerns\InteractsWithPrivateChannels;
class PrivateCacheChannel extends CacheChannel
{
use InteractsWithPrivateChannels;
}
@@ -0,0 +1,10 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Channels;
use Laravel\Reverb\Protocols\Pusher\Channels\Concerns\InteractsWithPrivateChannels;
class PrivateChannel extends Channel
{
use InteractsWithPrivateChannels;
}
@@ -0,0 +1,95 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Laravel\Reverb\Contracts\Connection;
use Laravel\Reverb\Protocols\Pusher\Contracts\ChannelManager;
class ClientEvent
{
/**
* Handle a Pusher client event.
*/
public static function handle(Connection $connection, array $event): void
{
Validator::make($event, [
'event' => ['required', 'string'],
'channel' => ['required', 'string'],
'data' => ['nullable', 'array'],
])->validate();
if (! Str::startsWith($event['event'], 'client-')) {
return;
}
if (! isset($event['channel'])) {
return;
}
$acceptClientEventsFrom = $connection->app()->acceptClientEventsFrom();
if (! in_array($acceptClientEventsFrom, ['all', 'members'])) {
// Client events are disabled, so we should reject the event...
$connection->send(json_encode([
'event' => 'pusher:error',
'data' => json_encode([
'code' => 4301,
'message' => 'The app does not have client messaging enabled.',
]),
]));
return;
}
$rebroadcastEvent = $event;
if ($acceptClientEventsFrom == 'members') {
$channel = app(ChannelManager::class)->find($event['channel']);
$channelConnection = $channel?->find($connection);
if (! $channelConnection) {
$connection->send(json_encode([
'event' => 'pusher:error',
'data' => json_encode([
'code' => 4009,
'message' => 'The client is not a member of the specified channel.',
]),
]));
return;
}
// Regenerate event payload, ensuring we only include the expected fields and the authenticated user_id...
$rebroadcastEvent = [
'event' => $event['event'],
'channel' => $event['channel'],
'data' => $event['data'] ?? null,
];
if ($userId = $channelConnection->data('user_id')) {
// Because public channels allow unauthenticated users, we may not have a user ID...
$rebroadcastEvent['user_id'] = $userId;
}
}
self::whisper(
$connection,
$rebroadcastEvent
);
}
/**
* Whisper a message to all connections on the channel associated with the event.
*/
public static function whisper(Connection $connection, array $payload): void
{
EventDispatcher::dispatch(
$connection->app(),
$payload,
$connection
);
}
}
@@ -0,0 +1,93 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Concerns;
use Laravel\Reverb\Application;
use Laravel\Reverb\Protocols\Pusher\Channels\CacheChannel;
use Laravel\Reverb\Protocols\Pusher\Channels\Channel;
use Laravel\Reverb\Protocols\Pusher\Channels\Concerns\InteractsWithPresenceChannels;
use Laravel\Reverb\Protocols\Pusher\Contracts\ChannelManager;
trait InteractsWithChannelInformation
{
/**
* Get meta / status information for the given channels.
*/
protected function infoForChannels(Application $application, array $channels, string $info): array
{
return collect($channels)->mapWithKeys(function ($channel) use ($application, $info) {
$name = $channel instanceof Channel ? $channel->name() : $channel;
return [$name => $this->info($application, $name, $info)];
})->all();
}
/**
* Get meta / status information for the given channel.
*
* @return array<string, array<string, int>>
*/
protected function info(Application $application, string $channel, string $info): array
{
$info = explode(',', $info);
$channel = app(ChannelManager::class)->for($application)->find($channel);
return array_filter(
$channel ? $this->occupiedInfo($channel, $info) : $this->unoccupiedInfo($info),
fn ($item) => $item !== null
);
}
/**
* Get channel information for the given occupied channel.
*/
private function occupiedInfo(Channel $channel, array $info): array
{
$count = count($channel->connections());
return [
'occupied' => in_array('occupied', $info) ? $count > 0 : null,
'user_count' => in_array('user_count', $info) && $this->isPresenceChannel($channel) ? $this->userCount($channel) : null,
'subscription_count' => in_array('subscription_count', $info) && ! $this->isPresenceChannel($channel) ? $count : null,
'cache' => in_array('cache', $info) && $this->isCacheChannel($channel) ? $channel->cachedPayload() : null,
];
}
/**
* Get channel information for the given unoccupied channel.
*/
private function unoccupiedInfo(array $info): array
{
return [
'occupied' => in_array('occupied', $info) ? false : null,
];
}
/**
* Determine if the given channel is a presence channel.
*/
protected function isPresenceChannel(Channel $channel): bool
{
return in_array(InteractsWithPresenceChannels::class, class_uses($channel));
}
/**
* Determine if the given channel is a cache channel.
*/
protected function isCacheChannel(Channel $channel): bool
{
return $channel instanceof CacheChannel;
}
/**
* Get the number of unique users subscribed to the presence channel.
*/
protected function userCount(Channel $channel): int
{
return collect($channel->connections())
->map(fn ($connection) => $connection->data())
->unique('user_id')
->count();
}
}
@@ -0,0 +1,29 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Concerns;
use Laravel\Reverb\Protocols\Pusher\Contracts\ChannelConnectionManager;
trait SerializesChannels
{
/**
* Prepare the channel instance values for serialization.
*
* @return array<string, mixed>
*/
public function __serialize(): array
{
return [
'name' => $this->name,
];
}
/**
* Restore the channel after serialization.
*/
public function __unserialize(array $values): void
{
$this->name = $values['name'];
$this->connections = app(ChannelConnectionManager::class)->for($this->name);
}
}
@@ -0,0 +1,51 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Contracts;
use Laravel\Reverb\Contracts\Connection;
use Laravel\Reverb\Protocols\Pusher\Channels\ChannelConnection;
interface ChannelConnectionManager
{
/**
* Get a channel connection manager for the given channel name.
*/
public function for(string $name): ChannelConnectionManager;
/**
* Add a connection.
*/
public function add(Connection $connection, array $data): void;
/**
* Remove a connection.
*/
public function remove(Connection $connection): void;
/**
* Find a connection.
*/
public function find(Connection $connection): ?ChannelConnection;
/**
* Find a connection by its ID.
*/
public function findById(string $id): ?ChannelConnection;
/**
* Get all of the connections.
*
* @return array<string, \Laravel\Reverb\Protocols\Pusher\Channels\ChannelConnection>
*/
public function all(): array;
/**
* Determine whether any connections remain on the channel.
*/
public function isEmpty(): bool;
/**
* Flush the channel connection manager.
*/
public function flush(): void;
}
@@ -0,0 +1,64 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Contracts;
use Laravel\Reverb\Application;
use Laravel\Reverb\Contracts\Connection;
use Laravel\Reverb\Protocols\Pusher\Channels\Channel;
interface ChannelManager
{
/**
* Get the application instance.
*/
public function app(): ?Application;
/**
* The application the channel manager should be scoped to.
*/
public function for(Application $application): ChannelManager;
/**
* Get all the channels.
*
* @return array<string, \Laravel\Reverb\Protocols\Pusher\Channels\Channel>
*/
public function all(): array;
/**
* Determine whether the given channel exists.
*/
public function exists(string $channel): bool;
/**
* Find the given channel.
*/
public function find(string $channel): ?Channel;
/**
* Find the given channel or create it if it doesn't exist.
*/
public function findOrCreate(string $channel): Channel;
/**
* Get all the connections for the given channels.
*
* @return array<string, \Laravel\Reverb\Protocols\Pusher\Channels\ChannelConnection>
*/
public function connections(?string $channel = null): array;
/**
* Unsubscribe from all channels.
*/
public function unsubscribeFromAll(Connection $connection): void;
/**
* Remove the given channel.
*/
public function remove(Channel $channel): void;
/**
* Flush the channel manager repository.
*/
public function flush(): void;
}
@@ -0,0 +1,59 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher;
use Illuminate\Support\Arr;
use Laravel\Reverb\Application;
use Laravel\Reverb\Contracts\Connection;
use Laravel\Reverb\Protocols\Pusher\Contracts\ChannelManager;
use Laravel\Reverb\ServerProviderManager;
use Laravel\Reverb\Servers\Reverb\Contracts\PubSubProvider;
class EventDispatcher
{
/**
* Dispatch a message to a channel.
*/
public static function dispatch(Application $app, array $payload, ?Connection $connection = null): void
{
$server = app(ServerProviderManager::class);
if ($server->shouldNotPublishEvents()) {
static::dispatchSynchronously($app, $payload, $connection);
return;
}
$data = [
'type' => 'message',
'application' => serialize($app),
'payload' => $payload,
];
if ($connection?->id() !== null) {
$data['socket_id'] = $connection?->id();
}
app(PubSubProvider::class)->publish($data);
}
/**
* Notify all connections subscribed to the given channel.
*/
public static function dispatchSynchronously(Application $app, array $payload, ?Connection $connection = null): void
{
$channels = Arr::wrap($payload['channels'] ?? $payload['channel'] ?? []);
foreach ($channels as $channel) {
unset($payload['channels']);
if (! $channel = app(ChannelManager::class)->for($app)->find($channel)) {
continue;
}
$payload['channel'] = $channel->name();
$channel->broadcast($payload, $connection);
}
}
}
@@ -0,0 +1,187 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher;
use Exception;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Laravel\Reverb\Contracts\Connection;
use Laravel\Reverb\Protocols\Pusher\Channels\CacheChannel;
use Laravel\Reverb\Protocols\Pusher\Channels\Channel;
use Laravel\Reverb\Protocols\Pusher\Contracts\ChannelManager;
class EventHandler
{
/**
* Create a new Pusher event instance.
*/
public function __construct(protected ChannelManager $channels)
{
//
}
/**
* Handle an incoming Pusher event.
*/
public function handle(Connection $connection, string $event, array $payload = []): void
{
$event = Str::after($event, 'pusher:');
match ($event) {
'connection_established' => $this->acknowledge($connection),
'subscribe' => $this->subscribe(
$connection,
$payload['channel'],
$payload['auth'] ?? null,
$payload['channel_data'] ?? null
),
'unsubscribe' => $this->unsubscribe($connection, $payload['channel']),
'ping' => $this->pong($connection),
'pong' => $connection->touch(),
default => throw new Exception('Unknown Pusher event: '.$event),
};
}
/**
* Acknowledge the connection.
*/
public function acknowledge(Connection $connection): void
{
$this->send($connection, 'connection_established', [
'socket_id' => $connection->id(),
'activity_timeout' => $connection->app()->activityTimeout(),
]);
}
/**
* Subscribe to the given channel.
*/
public function subscribe(Connection $connection, string $channel, ?string $auth = null, ?string $data = null): void
{
Validator::make([
'channel' => $channel,
'auth' => $auth,
'channel_data' => $data,
], [
'channel' => ['nullable', 'string'],
'auth' => ['nullable', 'string'],
'channel_data' => ['nullable', 'json'],
])->validate();
$channel = $this->channels
->for($connection->app())
->findOrCreate($channel);
$channel->subscribe($connection, $auth, $data);
$this->afterSubscribe($channel, $connection);
}
/**
* Carry out any actions that should be performed after a subscription.
*/
protected function afterSubscribe(Channel $channel, Connection $connection): void
{
$this->sendInternally($connection, 'subscription_succeeded', $channel->data(), $channel->name());
match (true) {
$channel instanceof CacheChannel => $this->sendCachedPayload($channel, $connection),
default => null,
};
}
/**
* Unsubscribe from the given channel.
*/
public function unsubscribe(Connection $connection, string $channel): void
{
$channel = $this->channels
->for($connection->app())
->find($channel)
?->unsubscribe($connection);
}
/**
* Send the cached payload for the given channel.
*/
protected function sendCachedPayload(CacheChannel $channel, Connection $connection): void
{
if ($channel->hasCachedPayload()) {
$connection->send(
json_encode($channel->cachedPayload())
);
return;
}
$this->send($connection, 'cache_miss', channel: $channel->name());
}
/**
* Respond to a ping on the given connection.
*/
public function pong(Connection $connection): void
{
static::send($connection, 'pong');
}
/**
* Send a ping to the given connection.
*/
public function ping(Connection $connection): void
{
$connection->usesControlFrames()
? $connection->control()
: static::send($connection, 'ping');
$connection->ping();
}
/**
* Send a response to the given connection.
*/
public function send(Connection $connection, string $event, array $data = [], ?string $channel = null): void
{
$connection->send(
static::formatPayload($event, $data, $channel)
);
}
/**
* Send an internal response to the given connection.
*/
public function sendInternally(Connection $connection, string $event, array $data = [], ?string $channel = null): void
{
$connection->send(
static::formatInternalPayload($event, $data, $channel)
);
}
/**
* Format the payload for the given event.
*/
public function formatPayload(string $event, array $data = [], ?string $channel = null, string $prefix = 'pusher:'): string|false
{
return json_encode(
array_filter([
'event' => $prefix.$event,
'data' => empty($data) ? null : json_encode($data),
'channel' => $channel,
])
);
}
/**
* Format the internal payload for the given event.
*/
public function formatInternalPayload(string $event, array $data = [], $channel = null): string|false
{
return json_encode(
array_filter([
'event' => 'pusher_internal:'.$event,
'data' => json_encode((object) $data),
'channel' => $channel,
])
);
}
}
@@ -0,0 +1,20 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Exceptions;
class ConnectionLimitExceeded extends PusherException
{
/**
* The error code associated with the exception.
*
* @var int
*/
protected $code = 4004;
/**
* The error message associated with the exception.
*
* @var string
*/
protected $message = 'Application is over connection quota';
}
@@ -0,0 +1,20 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Exceptions;
class ConnectionUnauthorized extends PusherException
{
/**
* The error code associated with the exception.
*
* @var int
*/
protected $code = 4009;
/**
* The error message associated with the exception.
*
* @var string
*/
protected $message = 'Connection is unauthorized';
}
@@ -0,0 +1,20 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Exceptions;
class InvalidOrigin extends PusherException
{
/**
* The error code associated with the exception.
*
* @var int
*/
protected $code = 4009;
/**
* The error message associated with the exception.
*
* @var string
*/
protected $message = 'Origin not allowed';
}
@@ -0,0 +1,46 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Exceptions;
use Exception;
abstract class PusherException extends Exception
{
/**
* The error code associated with the exception.
*
* @var int
*/
protected $code;
/**
* The error message associated with the exception.
*
* @var string
*/
protected $message;
/**
* Get the Pusher formatted error payload.
*/
public function payload(): array
{
return [
'event' => 'pusher:error',
'data' => json_encode([
'code' => $this->code,
'message' => $this->message,
]),
];
}
/**
* Get the encoded Pusher formatted error payload.
*/
public function message()
{
return json_encode(
$this->payload()
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Http\Controllers;
use Laravel\Reverb\Protocols\Pusher\MetricsHandler;
use Laravel\Reverb\Servers\Reverb\Http\Connection;
use Laravel\Reverb\Servers\Reverb\Http\Response;
use Psr\Http\Message\RequestInterface;
use React\Promise\PromiseInterface;
class ChannelController extends Controller
{
/**
* Handle the request.
*/
public function __invoke(RequestInterface $request, Connection $connection, string $appId, string $channel): PromiseInterface
{
$this->verify($request, $connection, $appId);
return app(MetricsHandler::class)->gather($this->application, 'channel', [
'channel' => $channel,
'info' => isset($this->query['info']) ? $this->query['info'].',occupied' : 'occupied',
])->then(fn ($channel) => new Response((object) $channel));
}
}
@@ -0,0 +1,37 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Http\Controllers;
use Laravel\Reverb\Protocols\Pusher\Concerns\InteractsWithChannelInformation;
use Laravel\Reverb\Protocols\Pusher\MetricsHandler;
use Laravel\Reverb\Servers\Reverb\Http\Connection;
use Laravel\Reverb\Servers\Reverb\Http\Response;
use Psr\Http\Message\RequestInterface;
use React\Promise\PromiseInterface;
class ChannelUsersController extends Controller
{
use InteractsWithChannelInformation;
/**
* Handle the request.
*/
public function __invoke(RequestInterface $request, Connection $connection, string $channel, string $appId): Response|PromiseInterface
{
$this->verify($request, $connection, $appId);
$channel = $this->channels->find($channel);
if (! $channel) {
return new Response((object) [], 404);
}
if (! $this->isPresenceChannel($channel)) {
return new Response((object) [], 400);
}
return app(MetricsHandler::class)
->gather($this->application, 'channel_users', ['channel' => $channel->name()])
->then(fn ($connections) => new Response(['users' => $connections]));
}
}
@@ -0,0 +1,25 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Http\Controllers;
use Laravel\Reverb\Protocols\Pusher\MetricsHandler;
use Laravel\Reverb\Servers\Reverb\Http\Connection;
use Laravel\Reverb\Servers\Reverb\Http\Response;
use Psr\Http\Message\RequestInterface;
use React\Promise\PromiseInterface;
class ChannelsController extends Controller
{
/**
* Handle the request.
*/
public function __invoke(RequestInterface $request, Connection $connection, string $appId): PromiseInterface
{
$this->verify($request, $connection, $appId);
return app(MetricsHandler::class)->gather($this->application, 'channels', [
'filter' => $this->query['filter_by_prefix'] ?? null,
'info' => $this->query['info'] ?? null,
])->then(fn ($channels) => new Response(['channels' => array_map(fn ($item) => (object) $item, $channels)]));
}
}
@@ -0,0 +1,23 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Http\Controllers;
use Laravel\Reverb\Protocols\Pusher\MetricsHandler;
use Laravel\Reverb\Servers\Reverb\Http\Connection;
use Laravel\Reverb\Servers\Reverb\Http\Response;
use Psr\Http\Message\RequestInterface;
use React\Promise\PromiseInterface;
class ConnectionsController extends Controller
{
/**
* Handle the request.
*/
public function __invoke(RequestInterface $request, Connection $connection, string $appId): PromiseInterface
{
$this->verify($request, $connection, $appId);
return app(MetricsHandler::class)->gather($this->application, 'connections')
->then(fn ($connections) => new Response(['connections' => count($connections)]));
}
}
@@ -0,0 +1,128 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Http\Controllers;
use Illuminate\Support\Arr;
use Laravel\Reverb\Application;
use Laravel\Reverb\Contracts\ApplicationProvider;
use Laravel\Reverb\Exceptions\InvalidApplication;
use Laravel\Reverb\Protocols\Pusher\Contracts\ChannelManager;
use Laravel\Reverb\Servers\Reverb\Concerns\ClosesConnections;
use Laravel\Reverb\Servers\Reverb\Http\Connection;
use Psr\Http\Message\RequestInterface;
use Symfony\Component\HttpKernel\Exception\HttpException;
abstract class Controller
{
use ClosesConnections;
/**
* Current application instance.
*/
protected ?Application $application = null;
/**
* Active channels for the application.
*/
protected ?ChannelManager $channels = null;
/**
* The incoming request's body.
*/
protected ?string $body;
/**
* The incoming request's query parameters.
*/
protected array $query = [];
/**
* Verify that the incoming request is valid.
*/
public function verify(RequestInterface $request, Connection $connection, $appId): void
{
parse_str($request->getUri()->getQuery(), $query);
$this->body = $request->getBody()->getContents();
$this->query = $query;
$this->setApplication($appId);
$this->setChannels();
$this->verifySignature($request);
}
/**
* Set the Reverb application instance for the incoming request's application ID.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
protected function setApplication(?string $appId): Application
{
if (! $appId) {
throw new HttpException(400, 'Application ID not provided.');
}
try {
return $this->application = app(ApplicationProvider::class)->findById($appId);
} catch (InvalidApplication $e) {
throw new HttpException(404, 'No matching application for ID ['.$appId.'].');
}
}
/**
* Set the Reverb channel manager instance for the application.
*/
protected function setChannels(): void
{
$this->channels = app(ChannelManager::class)->for($this->application);
}
/**
* Verify the Pusher authentication signature.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
protected function verifySignature(RequestInterface $request): void
{
$params = Arr::except($this->query, [
'auth_signature', 'body_md5', 'appId', 'appKey', 'channelName',
]);
if ($this->body !== '') {
$params['body_md5'] = md5($this->body);
}
ksort($params);
$signature = implode("\n", [
$request->getMethod(),
$request->getUri()->getPath(),
$this->formatQueryParametersForVerification($params),
]);
$signature = hash_hmac('sha256', $signature, $this->application->secret());
$authSignature = $this->query['auth_signature'] ?? '';
if ($signature !== $authSignature) {
throw new HttpException(401, 'Authentication signature invalid.');
}
}
/**
* Format the given parameters into the correct format for signature verification.
*/
protected static function formatQueryParametersForVerification(array $params): string
{
if (! is_array($params)) {
return $params;
}
return collect($params)->map(function ($value, $key) {
if (is_array($value)) {
$value = implode(',', $value);
}
return "{$key}={$value}";
})->implode('&');
}
}
@@ -0,0 +1,96 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Http\Controllers;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Support\Facades\Validator as ValidatorFacade;
use Laravel\Reverb\Protocols\Pusher\Concerns\InteractsWithChannelInformation;
use Laravel\Reverb\Protocols\Pusher\EventDispatcher;
use Laravel\Reverb\Protocols\Pusher\MetricsHandler;
use Laravel\Reverb\Servers\Reverb\Http\Connection;
use Laravel\Reverb\Servers\Reverb\Http\Response;
use Psr\Http\Message\RequestInterface;
use React\Promise\PromiseInterface;
use function React\Promise\all;
class EventsBatchController extends Controller
{
use InteractsWithChannelInformation;
/**
* Handle the request.
*/
public function __invoke(RequestInterface $request, Connection $connection, string $appId): Response|PromiseInterface
{
$this->verify($request, $connection, $appId);
$payload = json_decode($this->body, associative: true, flags: JSON_THROW_ON_ERROR);
$validator = $this->validator($payload);
if ($validator->fails()) {
return new Response($validator->errors(), 422);
}
$items = collect($payload['batch']);
$items = $items->map(function ($item) {
EventDispatcher::dispatch(
$this->application,
[
'event' => $item['name'],
'channel' => $item['channel'],
'data' => $item['data'],
],
isset($item['socket_id']) ? ($this->channels->connections()[$item['socket_id']] ?? null) : null
);
return isset($item['info']) ? app(MetricsHandler::class)->gather(
$this->application,
'channel',
['channel' => $item['channel'], 'info' => $item['info']]
) : [];
});
if ($items->contains(fn ($item) => ! empty($item))) {
return all($items)->then(function ($items) {
return new Response(['batch' => array_map(fn ($item) => (object) $item, $items)]);
});
}
return new Response(['batch' => (object) []]);
}
/**
* Get the info for the given channels.
*
* @return array<string, array<string, int>>
*/
protected function getInfo(string $channel, string $info): array
{
$info = explode(',', $info);
$count = count($this->channels->find($channel)->connections());
$info = [
'user_count' => in_array('user_count', $info) ? $count : null,
'subscription_count' => in_array('subscription_count', $info) ? $count : null,
];
return array_filter($info, fn ($item) => $item !== null);
}
/**
* Create a validator for the incoming request payload.
*/
protected function validator(array $payload): Validator
{
return ValidatorFacade::make($payload, [
'batch' => ['required', 'array'],
'batch.*.name' => ['required', 'string'],
'batch.*.data' => ['required', 'string'],
'batch.*.channel' => ['required_without:channels', 'string'],
'batch.*.socket_id' => ['string'],
'batch.*.info' => ['string'],
]);
}
}
@@ -0,0 +1,73 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Http\Controllers;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator as ValidatorFacade;
use Laravel\Reverb\Protocols\Pusher\Concerns\InteractsWithChannelInformation;
use Laravel\Reverb\Protocols\Pusher\EventDispatcher;
use Laravel\Reverb\Protocols\Pusher\MetricsHandler;
use Laravel\Reverb\Servers\Reverb\Http\Connection;
use Laravel\Reverb\Servers\Reverb\Http\Response;
use Psr\Http\Message\RequestInterface;
use React\Promise\PromiseInterface;
class EventsController extends Controller
{
use InteractsWithChannelInformation;
/**
* Handle the request.
*/
public function __invoke(RequestInterface $request, Connection $connection, string $appId): Response|PromiseInterface
{
$this->verify($request, $connection, $appId);
$payload = json_decode($this->body, associative: true, flags: JSON_THROW_ON_ERROR);
$validator = $this->validator($payload);
if ($validator->fails()) {
return new Response($validator->errors(), 422);
}
$channels = Arr::wrap($payload['channels'] ?? $payload['channel'] ?? []);
if ($except = $payload['socket_id'] ?? null) {
$except = $this->channels->connections()[$except] ?? null;
}
EventDispatcher::dispatch(
$this->application,
[
'event' => $payload['name'],
'channels' => $channels,
'data' => $payload['data'],
],
$except ? $except->connection() : null
);
if (isset($payload['info'])) {
return app(MetricsHandler::class)
->gather($this->application, 'channels', ['info' => $payload['info'], 'channels' => $channels])
->then(fn ($channels) => new Response(['channels' => array_map(fn ($channel) => (object) $channel, $channels)]));
}
return new Response((object) []);
}
/**
* Create a validator for the incoming request payload.
*/
protected function validator(array $payload): Validator
{
return ValidatorFacade::make($payload, [
'name' => ['required', 'string'],
'data' => ['required', 'string'],
'channels' => ['required_without:channel', 'array'],
'channel' => ['required_without:channels', 'string'],
'socket_id' => ['string'],
'info' => ['string'],
]);
}
}
@@ -0,0 +1,18 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Http\Controllers;
use Laravel\Reverb\Servers\Reverb\Http\Connection;
use Laravel\Reverb\Servers\Reverb\Http\Response;
use Psr\Http\Message\RequestInterface;
class HealthCheckController extends Controller
{
/**
* Handle the request.
*/
public function __invoke(RequestInterface $request, Connection $connection): Response
{
return new Response((object) ['health' => 'OK']);
}
}
@@ -0,0 +1,70 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Http\Controllers;
use Laravel\Reverb\Connection as ReverbConnection;
use Laravel\Reverb\Contracts\ApplicationProvider;
use Laravel\Reverb\Exceptions\InvalidApplication;
use Laravel\Reverb\Protocols\Pusher\Server as PusherServer;
use Laravel\Reverb\Servers\Reverb\Connection;
use Psr\Http\Message\RequestInterface;
use Ratchet\RFC6455\Messaging\FrameInterface;
class PusherController
{
/**
* Create a new controller instance.
*/
public function __construct(protected PusherServer $server, protected ApplicationProvider $applications)
{
//
}
/**
* Invoke the Reverb WebSocket server.
*/
public function __invoke(RequestInterface $request, Connection $connection, string $appKey): void
{
if (! $reverbConnection = $this->connection($request, $connection, $appKey)) {
return;
}
$connection->withMaxMessageSize($reverbConnection->app()->maxMessageSize());
$connection->onMessage(
fn ($message) => $this->server->message($reverbConnection, (string) $message)
);
$connection->onControl(
fn (FrameInterface $message) => $this->server->control($reverbConnection, $message)
);
$connection->onClose(
fn () => $this->server->close($reverbConnection)
);
$connection->openBuffer();
$this->server->open($reverbConnection);
}
/**
* Get the Reverb connection instance for the request.
*/
protected function connection(RequestInterface $request, Connection $connection, string $key): ?ReverbConnection
{
try {
$application = $this->applications->findByKey($key);
} catch (InvalidApplication $e) {
$connection->send('{"event":"pusher:error","data":"{\"code\":4001,\"message\":\"Application does not exist\"}"}');
return $connection->close();
}
return new ReverbConnection(
$connection,
$application,
$request->getHeader('Origin')[0] ?? null
);
}
}
@@ -0,0 +1,39 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Http\Controllers;
use Laravel\Reverb\ServerProviderManager;
use Laravel\Reverb\Servers\Reverb\Contracts\PubSubProvider;
use Laravel\Reverb\Servers\Reverb\Http\Connection;
use Laravel\Reverb\Servers\Reverb\Http\Response;
use Psr\Http\Message\RequestInterface;
use React\Promise\PromiseInterface;
class UsersTerminateController extends Controller
{
/**
* Handle the request.
*/
public function __invoke(RequestInterface $request, Connection $connection, string $appId, string $userId): Response|PromiseInterface
{
$this->verify($request, $connection, $appId);
if (app(ServerProviderManager::class)->subscribesToEvents()) {
return app(PubSubProvider::class)->publish([
'type' => 'terminate',
'application' => serialize($this->application),
'payload' => ['user_id' => $userId],
])->then(fn () => new Response((object) []));
}
$connections = collect($this->channels->connections());
$connections->each(function ($connection) use ($userId) {
if ((string) $connection->data()['user_id'] === $userId) {
$connection->disconnect();
}
});
return new Response((object) []);
}
}
@@ -0,0 +1,90 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Managers;
use Laravel\Reverb\Contracts\Connection;
use Laravel\Reverb\Protocols\Pusher\Channels\ChannelConnection;
use Laravel\Reverb\Protocols\Pusher\Contracts\ChannelConnectionManager;
class ArrayChannelConnectionManager implements ChannelConnectionManager
{
/**
* The channel name.
*/
protected string $name;
/**
* The underlying connections.
*
* @var array<string, \Laravel\Reverb\Protocols\Pusher\Channels\ChannelConnection>
*/
protected $connections = [];
/**
* Get a channel connection manager for the given channel name.
*/
public function for(string $name): ChannelConnectionManager
{
$this->name = $name;
return $this;
}
/**
* Add a connection.
*/
public function add(Connection $connection, array $data): void
{
$this->connections[$connection->id()] = new ChannelConnection($connection, $data);
}
/**
* Remove a connection.
*/
public function remove(Connection $connection): void
{
unset($this->connections[$connection->id()]);
}
/**
* Find a connection.
*/
public function find(Connection $connection): ?ChannelConnection
{
return $this->findById($connection->id());
}
/**
* Find a connection by its ID.
*/
public function findById(string $id): ?ChannelConnection
{
return $this->connections[$id] ?? null;
}
/**
* Get all of the connections.
*
* @return array<string, \Laravel\Reverb\Protocols\Pusher\Channels\ChannelConnection>
*/
public function all(): array
{
return $this->connections;
}
/**
* Determine whether any connections remain on the channel.
*/
public function isEmpty(): bool
{
return empty($this->connections);
}
/**
* Flush the channel connection manager.
*/
public function flush(): void
{
$this->connections = [];
}
}
@@ -0,0 +1,155 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher\Managers;
use Illuminate\Support\Arr;
use Laravel\Reverb\Application;
use Laravel\Reverb\Concerns\InteractsWithApplications;
use Laravel\Reverb\Contracts\ApplicationProvider;
use Laravel\Reverb\Contracts\Connection;
use Laravel\Reverb\Events\ChannelCreated;
use Laravel\Reverb\Events\ChannelRemoved;
use Laravel\Reverb\Protocols\Pusher\Channels\Channel;
use Laravel\Reverb\Protocols\Pusher\Channels\ChannelBroker;
use Laravel\Reverb\Protocols\Pusher\Contracts\ChannelManager as ChannelManagerInterface;
class ArrayChannelManager implements ChannelManagerInterface
{
use InteractsWithApplications;
/**
* The underlying array of applications and their channels.
*
* @var array<string, array<string, array<string, \Laravel\Reverb\Protocols\Pusher\Channels\Channel>>>
*/
protected $applications = [];
/**
* The application instance.
*
* @var \Laravel\Reverb\Application
*/
protected $application;
/**
* Get the application instance.
*/
public function app(): ?Application
{
return $this->application;
}
/**
* Get all the channels.
*
* @return array<string, \Laravel\Reverb\Protocols\Pusher\Channels\Channel>
*/
public function all(): array
{
return $this->channels();
}
/**
* Determine whether the given channel exists.
*/
public function exists(string $channel): bool
{
return isset($this->applications[$this->application->id()][$channel]);
}
/**
* Find the given channel
*/
public function find(string $channel): ?Channel
{
return $this->channels($channel);
}
/**
* Find the given channel or create it if it doesn't exist.
*/
public function findOrCreate(string $channelName): Channel
{
if ($channel = $this->find($channelName)) {
return $channel;
}
$channel = ChannelBroker::create($channelName);
$this->applications[$this->application->id()][$channel->name()] = $channel;
ChannelCreated::dispatch($channel);
return $channel;
}
/**
* Get all of the connections for the given channels.
*
* @return array<string, \Laravel\Reverb\Protocols\Pusher\Channels\ChannelConnection>
*/
public function connections(?string $channel = null): array
{
$channels = Arr::wrap($this->channels($channel));
return array_reduce($channels, function ($carry, $channel) {
return $carry + $channel->connections();
}, []);
}
/**
* Unsubscribe from all channels.
*/
public function unsubscribeFromAll(Connection $connection): void
{
foreach ($this->channels() as $channel) {
$channel->unsubscribe($connection);
}
}
/**
* Remove the given channel.
*/
public function remove(Channel $channel): void
{
unset($this->applications[$this->application->id()][$channel->name()]);
ChannelRemoved::dispatch($channel);
}
/**
* Get the given channel.
*/
public function channel(string $channel): ?Channel
{
return $this->channels($channel);
}
/**
* Get the channels for the application.
*
* @return \Laravel\Reverb\Protocols\Pusher\Channels\Channel|array<string, \Laravel\Reverb\Protocols\Pusher\Channels\Channel>|null
*/
public function channels(?string $channel = null): Channel|array|null
{
$channels = $this->applications[$this->application->id()] ?? [];
if (isset($channel)) {
return $channels[$channel] ?? null;
}
return $channels;
}
/**
* Flush the channel manager repository.
*/
public function flush(): void
{
app(ApplicationProvider::class)
->all()
->each(function (Application $application) {
$this->applications[$application->id()] = [];
});
}
}
@@ -0,0 +1,11 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher;
enum MetricType: string
{
case CONNECTIONS = 'connections';
case CHANNEL = 'channel';
case CHANNELS = 'channels';
case CHANNEL_USERS = 'channel_users';
}
@@ -0,0 +1,266 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher;
use Illuminate\Support\Str;
use Laravel\Reverb\Application;
use Laravel\Reverb\Protocols\Pusher\Concerns\InteractsWithChannelInformation;
use Laravel\Reverb\Protocols\Pusher\Contracts\ChannelManager;
use Laravel\Reverb\ServerProviderManager;
use Laravel\Reverb\Servers\Reverb\Contracts\PubSubProvider;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use function React\Promise\Timer\timeout;
class MetricsHandler
{
use InteractsWithChannelInformation;
/**
* The metrics being gathered.
*
* @var array<string, PendingMetric>
*/
protected array $metrics = [];
/**
* Create an instance of the metrics handler.
*/
public function __construct(
protected ServerProviderManager $serverProviderManager,
protected ChannelManager $channels,
protected PubSubProvider $pubSubProvider
) {
//
}
/**
* Gather the metrics for the given type.
*/
public function gather(Application $application, string $type, array $options = []): PromiseInterface
{
$metric = new PendingMetric(
Str::random(10),
$application,
MetricType::from($type),
$options
);
return $this->serverProviderManager->subscribesToEvents()
? $this->gatherMetricsFromSubscribers($metric)
: $this->promise($this->get($metric));
}
/**
* Get the metrics for the given type.
*/
public function get(PendingMetric $metric): array
{
return match ($metric->type()) {
MetricType::CHANNEL => $this->channel($metric),
MetricType::CHANNELS => $this->channels($metric),
MetricType::CHANNEL_USERS => $this->channelUsers($metric),
MetricType::CONNECTIONS => $this->connections($metric),
default => [],
};
}
/**
* Get the channel for the given application.
*/
protected function channel(PendingMetric $metric): array
{
return $this->info($metric->application(), $metric->option('channel'), $metric->option('info', ''));
}
/**
* Get the channels for the given application.
*/
protected function channels(PendingMetric $metric): array
{
if ($metric->option('channels')) {
return $this->infoForChannels($metric->application(), $metric->option('channels'), $metric->option('info', ''));
}
$channels = collect($this->channels->for($metric->application())->all());
if ($filter = ($metric->option('filter', false))) {
$channels = $channels->filter(fn ($channel) => Str::startsWith($channel->name(), $filter));
}
$channels = $channels->filter(fn ($channel) => count($channel->connections()) > 0);
return $this->infoForChannels(
$metric->application(),
$channels->all(),
$metric->option('info', '')
);
}
/**
* Get the channel users for the given application.
*/
protected function channelUsers(PendingMetric $metric): array
{
$channel = $this->channels->for($metric->application())->find($metric->option('channel'));
if (! $channel) {
return [];
}
return collect($channel->connections())
->map(fn ($connection) => $connection->data())
->unique('user_id')
->map(fn ($data) => ['id' => $data['user_id']])
->values()
->all();
}
/**
* Get the connections for the given application.
*/
protected function connections(PendingMetric $metric): array
{
return $this->channels->for($metric->application())->connections();
}
/**
* Gather metrics from all subscribers for the given type.
*/
protected function gatherMetricsFromSubscribers(PendingMetric $metric): PromiseInterface
{
$this->metrics[$metric->key()] = $metric;
$deferred = $this->listenForMetrics($metric);
$this->requestMetricsFromSubscribers($metric);
return timeout($deferred->promise(), 10)->then(
fn ($metrics) => $metrics,
fn () => $this->metrics[$metric->key()]?->resolve() ?? [],
)->then(
fn ($metrics) => $this->mergeSubscriberMetrics($metrics, $metric->type())
)->finally(
fn () => $this->stopListening($metric)
);
}
/**
* Request metrics from all subscribers.
*/
protected function requestMetricsFromSubscribers(PendingMetric $metric): void
{
$this->pubSubProvider->publish([
'type' => 'metrics',
'payload' => serialize($metric),
])->then(function ($total) use ($metric) {
$metric->setSubscriberCount($total);
});
}
/**
* Merge the given metrics into a single result set.
*/
protected function mergeSubscriberMetrics(array $metrics, MetricType $type): array
{
return match ($type) {
MetricType::CONNECTIONS => array_reduce($metrics, fn ($carry, $item) => array_merge($carry, $item), []),
MetricType::CHANNELS => $this->mergeChannels($metrics),
MetricType::CHANNEL => $this->mergeChannel($metrics),
MetricType::CHANNEL_USERS => collect($metrics)->flatten(1)->unique()->all(),
default => [],
};
}
/**
* Merge multiple channel instances into a single set.
*/
protected function mergeChannel(array $metrics): array
{
return collect($metrics)
->reduce(function ($carry, $item) {
collect($item)->each(fn ($value, $key) => $carry->put($key, match ($key) {
'occupied' => $carry->get($key, false) || $value,
'user_count' => $carry->get($key, 0) + $value,
'subscription_count' => $carry->get($key, 0) + $value,
default => $value,
}));
return $carry;
}, collect())
->all();
}
/**
* Merge multiple sets of channel instances into a single result set.
*/
protected function mergeChannels(array $metrics): array
{
return collect($metrics)
->reduce(function ($carry, $item) {
collect($item)->each(function ($data, $channel) use ($carry) {
$metrics = $carry->get($channel, []);
$metrics[] = $data;
$carry->put($channel, $metrics);
});
return $carry;
}, collect())
->map(fn ($metrics) => $this->mergeChannel($metrics))
->all();
}
/**
* Listen for metrics from subscribers.
*/
protected function listenForMetrics(PendingMetric $metric): Deferred
{
$deferred = new Deferred;
$this->pubSubProvider->on($metric->key(), function ($payload) use ($metric, $deferred) {
$pending = $this->metrics[$metric->key()];
$pending->append($payload['payload']);
if ($pending->resolvable()) {
$deferred->resolve($pending->resolve());
}
});
return $deferred;
}
/**
* Publish the metrics for the given type.
*/
public function publish(PendingMetric $metric): void
{
$this->pubSubProvider->publish([
'type' => $metric->key(),
'payload' => $this->get($metric),
]);
}
/**
* Stop listening for the given metric.
*/
protected function stopListening(PendingMetric $metric): void
{
unset($this->metrics[$metric->key()]);
$this->pubSubProvider->stopListening($metric->key());
}
/**
* Create a promise to resolve the given value.
*/
protected function promise(mixed $value): PromiseInterface
{
$deferred = new Deferred;
$promise = $deferred->promise();
$deferred->resolve($value);
return $promise;
}
}
@@ -0,0 +1,102 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher;
use Laravel\Reverb\Application;
class PendingMetric
{
/**
* The number of subscribers for the metric.
*/
protected ?int $subscribers = null;
/**
* The data for the metric.
*/
protected array $data = [];
/**
* Instantiate a new pending metric.
*/
public function __construct(
protected string $key,
protected Application $application,
protected MetricType $type,
protected array $options = [],
) {
//
}
/**
* Get the metric key.
*/
public function key(): string
{
return $this->key;
}
/**
* Get the metric type.
*/
public function type(): MetricType
{
return $this->type;
}
/**
* Get the application for the metric.
*/
public function application(): Application
{
return $this->application;
}
/**
* Get an option for the metric.
*/
public function option(string $key, mixed $default = null): mixed
{
return $this->options[$key] ?? $default;
}
/**
* Get the options for the metric.
*/
public function options(): array
{
return $this->options;
}
/**
* Set the subscriber count for the metric.
*/
public function setSubscriberCount(int $count): void
{
$this->subscribers = $count;
}
/**
* Append data to the metric.
*/
public function append(array $data): void
{
$this->data[] = $data;
}
/**
* Check if the metric is resolvable.
*/
public function resolvable(): bool
{
return $this->subscribers !== null && count($this->data) === $this->subscribers;
}
/**
* Resolve the data for the metric.
*/
public function resolve(): array
{
return $this->data;
}
}
@@ -0,0 +1,78 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher;
use Laravel\Reverb\Application;
use Laravel\Reverb\Protocols\Pusher\Contracts\ChannelManager;
use Laravel\Reverb\Servers\Reverb\Contracts\PubSubIncomingMessageHandler;
class PusherPubSubIncomingMessageHandler implements PubSubIncomingMessageHandler
{
protected array $events = [];
/**
* Handle an incoming message from the PubSub provider.
*/
public function handle(string $payload): void
{
$event = json_decode($payload, associative: true, flags: JSON_THROW_ON_ERROR);
$this->processEventListeners($event);
$application = unserialize($event['application'] ?? null, ['allowed_classes' => [Application::class]]);
$except = isset($event['socket_id']) ?
app(ChannelManager::class)->for($application)->connections()[$event['socket_id']] ?? null
: null;
match ($event['type'] ?? null) {
'message' => EventDispatcher::dispatchSynchronously(
$application,
$event['payload'],
$except?->connection()
),
'metrics' => app(MetricsHandler::class)->publish(
unserialize($event['payload'], ['allowed_classes' => [
Application::class, PendingMetric::class, MetricType::class,
]])
),
'terminate' => collect(app(ChannelManager::class)->for($application)->connections())
->each(function ($connection) use ($event) {
if ((string) $connection->data()['user_id'] === $event['payload']['user_id']) {
$connection->disconnect();
}
}),
default => null,
};
}
/**
* Process the given event.
*/
protected function processEventListeners(array $event): void
{
foreach ($this->events as $eventName => $listeners) {
if (($event['type'] ?? null) === $eventName) {
foreach ($listeners as $listener) {
$listener($event);
}
}
}
}
/**
* Listen for the given event.
*/
public function listen(string $event, callable $callback): void
{
$this->events[$event][] = $callback;
}
/**
* Stop listening for the given event.
*/
public function stopListening(string $event): void
{
unset($this->events[$event]);
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
namespace Laravel\Reverb\Protocols\Pusher;
use Exception;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Laravel\Reverb\Contracts\Connection;
use Laravel\Reverb\Events\MessageReceived;
use Laravel\Reverb\Loggers\Log;
use Laravel\Reverb\Protocols\Pusher\Contracts\ChannelManager;
use Laravel\Reverb\Protocols\Pusher\Exceptions\ConnectionLimitExceeded;
use Laravel\Reverb\Protocols\Pusher\Exceptions\InvalidOrigin;
use Laravel\Reverb\Protocols\Pusher\Exceptions\PusherException;
use Ratchet\RFC6455\Messaging\Frame;
use Ratchet\RFC6455\Messaging\FrameInterface;
use Throwable;
class Server
{
/**
* Create a new server instance.
*/
public function __construct(protected ChannelManager $channels, protected EventHandler $handler)
{
//
}
/**
* Handle the a client connection.
*/
public function open(Connection $connection): void
{
try {
$this->ensureWithinConnectionLimit($connection);
$this->verifyOrigin($connection);
$connection->touch();
$this->handler->handle($connection, 'pusher:connection_established');
Log::info('Connection Established', $connection->id());
} catch (Exception $e) {
$this->error($connection, $e);
}
}
/**
* Handle a new message received by the connected client.
*/
public function message(Connection $from, string $message): void
{
Log::info('Message Received', $from->id());
Log::message($message);
$from->touch();
try {
$event = json_decode($message, associative: true, flags: JSON_THROW_ON_ERROR);
if (Str::isJson($event['data'] ?? null)) {
$event['data'] = json_decode($event['data'], associative: true, flags: JSON_THROW_ON_ERROR);
}
Validator::make($event, ['event' => ['required', 'string']])->validate();
match (Str::startsWith($event['event'], 'pusher:')) {
true => $this->handler->handle(
$from,
$event['event'],
empty($event['data']) ? [] : $event['data'],
),
default => ClientEvent::handle($from, $event)
};
Log::info('Message Handled', $from->id());
MessageReceived::dispatch($from, $message);
} catch (Throwable $e) {
$this->error($from, $e);
}
}
/**
* Handle a low-level WebSocket control frame.
*/
public function control(Connection $from, FrameInterface $message): void
{
Log::info('Control Frame Received', $from->id());
Log::message($message);
$from->setUsesControlFrames();
if (in_array($message->getOpcode(), [Frame::OP_PING, Frame::OP_PONG], strict: true)) {
$from->touch();
}
}
/**
* Handle a client disconnection.
*/
public function close(Connection $connection): void
{
$this->channels
->for($connection->app())
->unsubscribeFromAll($connection);
$connection->disconnect();
Log::info('Connection Closed', $connection->id());
}
/**
* Handle an error.
*/
public function error(Connection $connection, Throwable $exception): void
{
if ($exception instanceof PusherException) {
$connection->send(json_encode($exception->payload()));
Log::error('Message from '.$connection->id().' resulted in a pusher error');
Log::info($exception->getMessage());
return;
}
$connection->send(json_encode([
'event' => 'pusher:error',
'data' => json_encode([
'code' => 4200,
'message' => 'Invalid message format',
]),
]));
Log::error('Message from '.$connection->id().' resulted in an unknown error');
Log::info($exception->getMessage());
}
/**
* Ensure the server is within the connection limit.
*/
protected function ensureWithinConnectionLimit(Connection $connection): void
{
if (! $connection->app()->hasMaxConnectionLimit()) {
return;
}
$connections = $this->channels->for($connection->app())->connections();
if (count($connections) >= $connection->app()->maxConnections()) {
throw new ConnectionLimitExceeded;
}
}
/**
* Verify the origin of the connection.
*
* @throws \Laravel\Reverb\Exceptions\InvalidOrigin
*/
protected function verifyOrigin(Connection $connection): void
{
$allowedOrigins = $connection->app()->allowedOrigins();
if (in_array('*', $allowedOrigins)) {
return;
}
$origin = parse_url($connection->origin(), PHP_URL_HOST);
foreach ($allowedOrigins as $allowedOrigin) {
if (Str::is($allowedOrigin, $origin)) {
return;
}
}
throw new InvalidOrigin;
}
}