diff --git a/config/websockets.php b/config/websockets.php index cff88fd..fa7ba0b 100644 --- a/config/websockets.php +++ b/config/websockets.php @@ -55,6 +55,29 @@ return [ */ 'auth_resolver' => null, + /* + |-------------------------------------------------------------------------- + | Identity Formatter + |-------------------------------------------------------------------------- + | + | Class used by admin tooling (`php artisan websockets:watch -v`) to render + | the "User" column for each connection. The default formatter handles the + | typical Eloquent User shape (id / name / username / email) and produces + | strings like `#42 - Alice | alice42 - alice@example.com`, dropping any + | suffix whose source field is missing. + | + | Apps with non-User auth subjects (multi-tenant company accounts, api + | clients, etc.) can implement + | `BlaxSoftware\LaravelWebSockets\Contracts\IdentityFormatter` and either + | name the class here, or bind it directly: + | + | $this->app->bind(IdentityFormatter::class, MyFormatter::class); + | + | Leave null to use the package default. + | + */ + 'identity_formatter' => null, + /* |-------------------------------------------------------------------------- | Max Concurrent Children (Fork Limit) diff --git a/src/Console/Commands/WatchStats.php b/src/Console/Commands/WatchStats.php index fa60721..b2690b5 100644 --- a/src/Console/Commands/WatchStats.php +++ b/src/Console/Commands/WatchStats.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace BlaxSoftware\LaravelWebSockets\Console\Commands; +use BlaxSoftware\LaravelWebSockets\Contracts\IdentityFormatter; use BlaxSoftware\LaravelWebSockets\Services\WebsocketService; use Illuminate\Console\Command; @@ -145,21 +146,20 @@ class WatchStats extends Command return 'Guest'; } - $user = WebsocketService::getAuth($socketId); - if (! $user) { - // Authed but the per-socket user blob expired / was evicted. + $subject = WebsocketService::getAuth($socketId); + if (! $subject) { + // Authed-but-expired: the socket is in ws_socket_authed_users + // but the per-socket user blob has fallen out of cache. Show the + // bare id from authedUsers so the connection isn't mislabeled + // as a Guest. return '#' . $authedUsers[$socketId] . ''; } - // Try common identity fields in order of usefulness. Fall back to the - // user id so we always have something to display. - $label = $user->name - ?? $user->display_name - ?? $user->username - ?? $user->email - ?? ('#' . ($user->id ?? '?')); - - return (string) $label; + // Delegate formatting to the bound IdentityFormatter so apps with + // non-User subjects (Company, ApiClient, etc.) can render their own + // shape without forking the package. + $formatter = app(IdentityFormatter::class); + return $formatter->format($subject, $socketId); } private function formatDuration(string $socketId, int $now): string diff --git a/src/Contracts/IdentityFormatter.php b/src/Contracts/IdentityFormatter.php new file mode 100644 index 0000000..56b154c --- /dev/null +++ b/src/Contracts/IdentityFormatter.php @@ -0,0 +1,45 @@ +app->bind( + * \BlaxSoftware\LaravelWebSockets\Contracts\IdentityFormatter::class, + * \App\Websockets\MyIdentityFormatter::class, + * ); + * + * Or via config (`config/websockets.php`): + * + * 'identity_formatter' => \App\Websockets\MyIdentityFormatter::class, + */ +interface IdentityFormatter +{ + /** + * Return a human-readable label for the given subject. + * + * @param mixed $subject Whatever was cached via setUserAuthed() — an + * Eloquent model, a stdClass, null for guests, + * or an arbitrary value the app stored. + * @param string $socketId The socket id this subject is attached to. + * Useful if the formatter wants to pull + * additional context from elsewhere. + */ + public function format(mixed $subject, string $socketId): string; +} diff --git a/src/Identity/DefaultIdentityFormatter.php b/src/Identity/DefaultIdentityFormatter.php new file mode 100644 index 0000000..8e299f9 --- /dev/null +++ b/src/Identity/DefaultIdentityFormatter.php @@ -0,0 +1,64 @@ + - | - ` with each suffix + * dropped if the corresponding field is missing or null. Returns "Guest" + * for null subjects and for subjects that have no usable id at all. + * + * Apps with a different subject shape (Company, ApiClient, custom auth blob) + * should implement `IdentityFormatter` and bind it in their service provider + * — see the interface docblock for an example. + */ +class DefaultIdentityFormatter implements IdentityFormatter +{ + public function format(mixed $subject, string $socketId): string + { + if (! $subject) { + return 'Guest'; + } + + $id = $this->read($subject, 'id'); + if ($id === null || $id === '') { + return 'Guest'; + } + + $out = '#' . $id; + + if ($name = $this->read($subject, 'name')) { + $out .= ' - ' . $name; + } + if ($username = $this->read($subject, 'username')) { + $out .= ' | ' . $username; + } + if ($email = $this->read($subject, 'email')) { + $out .= ' - ' . $email; + } + + return $out; + } + + /** + * Best-effort property read across Eloquent models, stdClass, and arrays. + */ + private function read(mixed $subject, string $key): mixed + { + if (is_array($subject)) { + return $subject[$key] ?? null; + } + if (is_object($subject)) { + // Eloquent and stdClass both support property access; missing + // attributes on Eloquent return null instead of throwing. + return $subject->{$key} ?? null; + } + return null; + } +} diff --git a/src/WebSocketsServiceProvider.php b/src/WebSocketsServiceProvider.php index 1600de6..0f94ca5 100644 --- a/src/WebSocketsServiceProvider.php +++ b/src/WebSocketsServiceProvider.php @@ -31,6 +31,7 @@ class WebSocketsServiceProvider extends ServiceProvider $this->registerWebSocketHandler(); $this->registerRouter(); $this->registerManagers(); + $this->registerIdentityFormatter(); $this->registerBroadcastAuthRoute(); $this->registerCommands(); } @@ -81,6 +82,30 @@ class WebSocketsServiceProvider extends ServiceProvider }); } + /** + * Bind the identity formatter used by admin tooling (`websockets:watch -v`). + * + * Resolution order: + * 1. Whatever is already bound to `Contracts\IdentityFormatter` — apps + * that called `$this->app->bind()` in their own provider win, because + * this register runs in `boot()` after their `register()`. + * 2. The class named in `config('websockets.identity_formatter')`. + * 3. The package default (`DefaultIdentityFormatter`). + */ + protected function registerIdentityFormatter() + { + if ($this->app->bound(Contracts\IdentityFormatter::class)) { + return; + } + + $configured = config('websockets.identity_formatter'); + $class = is_string($configured) && $configured !== '' + ? $configured + : Identity\DefaultIdentityFormatter::class; + + $this->app->singleton(Contracts\IdentityFormatter::class, $class); + } + protected function registerBroadcastAuthRoute() { if (! Route::has('broadcasting/auth')) {