From 9e7c2575f629c77cdde2681e056d4145d04ea8d4 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Thu, 16 Apr 2026 09:02:25 +0200 Subject: [PATCH] feat: WS introspection - list controllers and methods for debugging - Send 'websocket' to list all controllers with methods and metadata - Send 'auth' to list all methods on AuthController - Shows need_auth, lifecycle hooks (boot/booted/unboot) per controller - Only enabled in local env or via WEBSOCKET_INTROSPECTION=true - Never active in production unless explicitly enabled --- config/websockets.php | 15 +++ src/Websocket/Controller.php | 184 +++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/config/websockets.php b/config/websockets.php index 48b884c..e69aafc 100644 --- a/config/websockets.php +++ b/config/websockets.php @@ -24,6 +24,21 @@ return [ */ 'hot_reload' => env('WEBSOCKET_HOT_RELOAD', env('APP_DEBUG', false)), + /* + |-------------------------------------------------------------------------- + | Introspection (Development Only) + |-------------------------------------------------------------------------- + | + | When enabled, sending an event with only a controller prefix (e.g. + | "auth" or "websocket") returns a list of available methods, auth + | requirements, and lifecycle hooks. Useful for debugging. + | + | Always allowed in 'local' environment. Set to true to enable in + | other environments (e.g. staging). Never enable in production. + | + */ + 'introspection' => env('WEBSOCKET_INTROSPECTION', false), + /* |-------------------------------------------------------------------------- | Max Concurrent Children (Fork Limit) diff --git a/src/Websocket/Controller.php b/src/Websocket/Controller.php index 98f667a..7b4513b 100644 --- a/src/Websocket/Controller.php +++ b/src/Websocket/Controller.php @@ -59,6 +59,12 @@ class Controller LocalChannelManager $channelManager ) { $event = self::get_event($message); + + // Introspection: single-part event like "auth" or "websocket" + if (count($event) === 1) { + return self::handleIntrospection($connection, $message, $event[0]); + } + if (count($event) !== 2) { return self::send_error($connection, $message, 'Event unknown'); } @@ -340,6 +346,184 @@ class Controller }); } + /** + * Handle introspection requests (dev-only). + * + * - `websocket` → list all controllers and their methods + * - `auth` → list all methods on AuthController + */ + private static function handleIntrospection( + ConnectionInterface $connection, + array $message, + string $prefix + ) { + // Only allow in local environment or when explicitly enabled + $allowed = config('websockets.introspection', false) + || app()->environment('local'); + + if (! $allowed) { + return self::send_error($connection, $message, 'Introspection disabled'); + } + + $prefix = static::without_uniquifyer($prefix); + + // Special case: "websocket" lists all controllers + if ($prefix === 'websocket') { + $controllers = self::introspectAllControllers(); + $connection->send(json_encode([ + 'event' => ($message['event'] ?? 'websocket') . ':response', + 'data' => $controllers, + 'channel' => $message['channel'] ?? null, + ])); + return $controllers; + } + + // Specific controller: "auth" → AuthController + $controllerClass = ControllerResolver::resolve($prefix); + if (! $controllerClass) { + return self::send_error($connection, $message, "No controller found for '{$prefix}'"); + } + + $info = self::introspectController($controllerClass, $prefix); + $connection->send(json_encode([ + 'event' => ($message['event'] ?? $prefix) . ':response', + 'data' => $info, + 'channel' => $message['channel'] ?? null, + ])); + return $info; + } + + /** + * Introspect a single controller: list public methods, auth, lifecycle. + */ + private static function introspectController(string $controllerClass, string $prefix): array + { + $reflection = new \ReflectionClass($controllerClass); + $needAuth = true; + + // Check need_auth property + if ($reflection->hasProperty('need_auth')) { + $prop = $reflection->getProperty('need_auth'); + $needAuth = $prop->getDefaultValue() ?? true; + } + + // Check lifecycle methods + $hasBoot = $reflection->getMethod('boot')->getDeclaringClass()->getName() !== self::class; + $hasBooted = $reflection->getMethod('booted')->getDeclaringClass()->getName() !== self::class; + $hasUnboot = $reflection->getMethod('unboot')->getDeclaringClass()->getName() !== self::class; + + // Collect public non-inherited, non-magic methods + $baseMethods = get_class_methods(self::class); + $methods = []; + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + $name = $method->getName(); + + // Skip inherited base methods, constructors, and magic methods + if (in_array($name, $baseMethods, true)) continue; + if (str_starts_with($name, '__')) continue; + if ($method->isStatic()) continue; + + $info = ['name' => $name, 'event' => "{$prefix}.{$name}"]; + + // Add parameter names (skip $connection, $data, $channel) + $params = []; + foreach ($method->getParameters() as $i => $param) { + if ($i < 3) continue; // skip standard ($connection, $data, $channel) + $params[] = '$' . $param->getName(); + } + if ($params) $info['extra_params'] = $params; + + $methods[] = $info; + } + + return [ + 'controller' => $controllerClass, + 'prefix' => $prefix, + 'need_auth' => $needAuth, + 'lifecycle' => array_filter([ + 'boot' => $hasBoot, + 'booted' => $hasBooted, + 'unboot' => $hasUnboot, + ]), + 'methods' => $methods, + ]; + } + + /** + * Scan and introspect all available controllers. + */ + private static function introspectAllControllers(): array + { + // Ensure controllers are scanned + ControllerResolver::scanControllers(); + + $result = []; + $seen = []; + + // Scan app controllers directory + $appPath = function_exists('app_path') + ? app_path('Websocket/Controllers') + : null; + + if ($appPath && is_dir($appPath)) { + self::scanControllersInPath($appPath, '\\App\\Websocket\\Controllers\\', $result, $seen); + } + + // Scan vendor controllers + $vendorPath = __DIR__ . '/Controllers'; + if (is_dir($vendorPath)) { + self::scanControllersInPath($vendorPath, '\\BlaxSoftware\\LaravelWebSockets\\Websocket\\Controllers\\', $result, $seen); + } + + return [ + 'controllers' => $result, + 'total' => count($result), + ]; + } + + /** + * Recursively scan a directory for controllers and introspect them. + */ + private static function scanControllersInPath( + string $path, + string $namespace, + array &$result, + array &$seen, + string $subNamespace = '' + ): void { + $iterator = new \DirectoryIterator($path); + + foreach ($iterator as $item) { + if ($item->isDot()) continue; + + if ($item->isDir()) { + self::scanControllersInPath( + $item->getPathname(), + $namespace, + $result, + $seen, + $subNamespace . $item->getFilename() . '\\' + ); + } elseif ($item->isFile() && $item->getExtension() === 'php') { + $className = $item->getBasename('.php'); + if (! str_ends_with($className, 'Controller')) continue; + + $fullClass = $namespace . $subNamespace . $className; + if (isset($seen[$fullClass])) continue; + $seen[$fullClass] = true; + + if (! class_exists($fullClass, true)) continue; + if (! is_subclass_of($fullClass, self::class)) continue; + + // Derive event prefix from class name + $shortName = str_replace('Controller', '', $className); + $prefix = strtolower(preg_replace('/([a-z])([A-Z])/', '$1-$2', $shortName)); + + $result[] = self::introspectController($fullClass, $prefix); + } + } + } + private static function send_error( ConnectionInterface $connection, array $message,