From f030ff1fbfc0fc5ac8e2bfde2612c5a781a2356c Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Wed, 29 Apr 2026 09:26:53 +0200 Subject: [PATCH] feat: integrate websocket routes into Laravel's route list; implement RouteListInjector for attribute-tagged methods and legacy controllers; add tests for route injection and collection --- src/Routing/RouteListInjector.php | 196 ++++++++++++++++++++++++++ src/WebSocketsServiceProvider.php | 19 +++ src/Websocket/Controller.php | 8 +- tests/Unit/DispatcherFallbackTest.php | 17 +++ tests/Unit/RouteListInjectorTest.php | 120 ++++++++++++++++ 5 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 src/Routing/RouteListInjector.php create mode 100644 tests/Unit/RouteListInjectorTest.php diff --git a/src/Routing/RouteListInjector.php b/src/Routing/RouteListInjector.php new file mode 100644 index 0000000..410c7a3 --- /dev/null +++ b/src/Routing/RouteListInjector.php @@ -0,0 +1,196 @@ + $target) { + $router->addRoute( + ['WS', 'WSS'], + $event, + ['uses' => $target['class'] . '@' . $target['method']] + ); + } + } + + /** + * Build a sorted event-name → target map merged from both sources. + * + * @return array + */ + public static function collect(): array + { + $events = []; + + // 1. Legacy `App\Websocket\Controllers\*` controllers + foreach (self::scanLegacyControllers() as $class => $methods) { + $prefix = EventRegistry::eventPrefixFor($class, 'App\\Websocket\\Controllers\\'); + + // The `$need_auth` property defaults to true on the base controller + // unless the subclass overrides it to false (see existing pattern + // on `*GuestController`). Use a static reflection read so we can + // surface auth status in route:list. + $needAuth = self::classNeedsAuth($class); + + foreach ($methods as $method) { + $events[$prefix . '.' . $method] = [ + 'class' => $class, + 'method' => $method, + 'source' => 'ws-controller', + 'needAuth' => $needAuth, + ]; + } + } + + // 2. Attribute-tagged HTTP controllers — these win on collision + foreach (EventRegistry::map() as $event => $target) { + $events[$event] = [ + 'class' => $target['class'], + 'method' => $target['method'], + 'source' => 'http-attribute', + 'needAuth' => $target['needAuth'], + ]; + } + + ksort($events); + return $events; + } + + /** + * Scan App\Websocket\Controllers/ recursively for controllers and their + * public, declared, non-lifecycle methods. + * + * @return array> + */ + private static function scanLegacyControllers(): array + { + if (! function_exists('app_path')) { + return []; + } + + try { + $base = app_path('Websocket/Controllers'); + } catch (\Throwable) { + return []; + } + + if (! is_dir($base)) { + return []; + } + + $found = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $relative = ltrim(str_replace($base, '', $file->getPathname()), DIRECTORY_SEPARATOR); + $className = 'App\\Websocket\\Controllers\\' . str_replace([DIRECTORY_SEPARATOR, '.php'], ['\\', ''], $relative); + + if (! class_exists($className, true)) { + continue; + } + + try { + $reflection = new \ReflectionClass($className); + } catch (\Throwable) { + continue; + } + + if ($reflection->isAbstract() || $reflection->isInterface()) { + continue; + } + + $methods = []; + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->isStatic() || $method->isConstructor() || $method->isAbstract()) { + continue; + } + if ($method->getDeclaringClass()->getName() !== $className) { + continue; + } + if (in_array($method->getName(), self::LIFECYCLE_METHODS, true)) { + continue; + } + + $methods[] = $method->getName(); + } + + if ($methods) { + $found[$className] = $methods; + } + } + + return $found; + } + + private static function classNeedsAuth(string $class): bool + { + try { + $reflection = new \ReflectionClass($class); + } catch (\Throwable) { + return true; + } + + if (! $reflection->hasProperty('need_auth')) { + return true; // base default + } + + $prop = $reflection->getProperty('need_auth'); + + // Try to read default value (works whether the property is public + // or protected, because we're reading the declared default, not + // an instance value). + $defaults = $reflection->getDefaultProperties(); + if (array_key_exists('need_auth', $defaults)) { + return (bool) $defaults['need_auth']; + } + + return true; + } +} diff --git a/src/WebSocketsServiceProvider.php b/src/WebSocketsServiceProvider.php index 0f94ca5..c09364d 100644 --- a/src/WebSocketsServiceProvider.php +++ b/src/WebSocketsServiceProvider.php @@ -34,6 +34,25 @@ class WebSocketsServiceProvider extends ServiceProvider $this->registerIdentityFormatter(); $this->registerBroadcastAuthRoute(); $this->registerCommands(); + $this->registerRouteListIntegration(); + } + + /** + * Inject every WS event as a `WS|WSS` route in the Laravel router so + * `php artisan route:list` shows the realtime surface alongside HTTP. + * + * Only runs in the console: HTTP requests can never carry a `WS` or + * `WSS` method, so the routes are inert for production dispatch, but + * registering them per-request would still cost a directory scan we + * don't need to pay there. + */ + protected function registerRouteListIntegration() + { + if (! $this->app->runningInConsole()) { + return; + } + + Routing\RouteListInjector::inject($this->app['router']); } public function register() diff --git a/src/Websocket/Controller.php b/src/Websocket/Controller.php index b2e1c83..40acf82 100644 --- a/src/Websocket/Controller.php +++ b/src/Websocket/Controller.php @@ -80,7 +80,13 @@ class Controller // #[Websocket] attribute may handle this event. The registry // exposes a flat event-name → callable map built by // reflecting attribute-tagged methods at scan time. - $target = EventRegistry::resolve($message['event']); + // + // IMPORTANT: registry keys are stored without the client-side + // `[uniquifier]` segment (e.g. `flightschool.index[abc123]`), + // so we rebuild the lookup name from the cleaned prefix + + // cleaned method instead of using `$message['event']` raw. + $cleanEvent = $eventPrefix . '.' . $method; + $target = EventRegistry::resolve($cleanEvent); if ($target) { return self::dispatchHttpAttributeTarget($connection, $message, $target); } diff --git a/tests/Unit/DispatcherFallbackTest.php b/tests/Unit/DispatcherFallbackTest.php index b1ae9bc..f508cf2 100644 --- a/tests/Unit/DispatcherFallbackTest.php +++ b/tests/Unit/DispatcherFallbackTest.php @@ -104,6 +104,23 @@ class DispatcherFallbackTest extends TestCase $this->assertSame('test-channel', $result['sent']['channel']); } + /** @test */ + public function it_strips_the_client_side_uniquifier_before_registry_lookup() + { + // Real-world client-side wrappers append a per-request uniquifier in + // square brackets (e.g. `dispatchable.array[abc123]`) so the response + // can be correlated. The dispatcher must strip that before looking + // up the registry — otherwise every WS client request would 404 the + // registry path even though the underlying event is well-formed. + $result = $this->dispatch($this->message('dispatchable.array[abc123]')); + + $this->assertSame(['kind' => 'array', 'ok' => true], $result['return']); + // The :response envelope echoes back the ORIGINAL event name (with + // uniquifier) so clients can match the response to the request. + $this->assertSame('dispatchable.array[abc123]:response', $result['sent']['event']); + $this->assertSame(['kind' => 'array', 'ok' => true], $result['sent']['data']); + } + /** @test */ public function it_falls_through_to_the_send_error_when_neither_resolver_nor_registry_matches() { diff --git a/tests/Unit/RouteListInjectorTest.php b/tests/Unit/RouteListInjectorTest.php new file mode 100644 index 0000000..f2d5825 --- /dev/null +++ b/tests/Unit/RouteListInjectorTest.php @@ -0,0 +1,120 @@ + self::FIXTURES_NS, + ]); + } + + public function tearDown(): void + { + EventRegistry::clear(); + parent::tearDown(); + } + + /** @test */ + public function it_injects_a_route_per_attribute_tagged_method() + { + $router = new Router(app('events'), app()); + + RouteListInjector::inject($router); + + $routes = $router->getRoutes(); + + // At least one of the fixture events should be present + $matched = collect($routes)->first(fn($r) => $r->uri() === 'plain.alpha'); + $this->assertNotNull($matched, 'Expected route plain.alpha to be registered'); + } + + /** @test */ + public function injected_routes_use_ws_and_wss_as_methods() + { + $router = new Router(app('events'), app()); + + RouteListInjector::inject($router); + + $matched = collect($router->getRoutes())->first(fn($r) => $r->uri() === 'plain.alpha'); + + $this->assertNotNull($matched); + $this->assertContains('WS', $matched->methods()); + $this->assertContains('WSS', $matched->methods()); + + // No HTTP methods + $this->assertNotContains('GET', $matched->methods()); + $this->assertNotContains('POST', $matched->methods()); + } + + /** @test */ + public function injected_routes_carry_a_controller_action_string() + { + $router = new Router(app('events'), app()); + + RouteListInjector::inject($router); + + $matched = collect($router->getRoutes())->first(fn($r) => $r->uri() === 'plain.alpha'); + + $this->assertNotNull($matched); + $action = $matched->getActionName(); + $this->assertSame( + self::FIXTURES_NS . '\\PlainController@alpha', + $action + ); + } + + /** @test */ + public function collect_includes_attribute_tagged_events() + { + $events = RouteListInjector::collect(); + + $this->assertArrayHasKey('plain.alpha', $events); + $this->assertArrayHasKey('plain.bravo', $events); + $this->assertArrayHasKey('api-v1-me.show', $events); + } + + /** @test */ + public function collect_marks_attribute_sourced_events() + { + $events = RouteListInjector::collect(); + + $this->assertSame('http-attribute', $events['plain.alpha']['source']); + } + + /** @test */ + public function collect_propagates_need_auth_for_attribute_targets() + { + $events = RouteListInjector::collect(); + + $this->assertTrue($events['api-v1-me.show']['needAuth']); + $this->assertFalse($events['plain.alpha']['needAuth']); + } + + /** @test */ + public function collect_returns_a_sorted_map() + { + $events = RouteListInjector::collect(); + $keys = array_keys($events); + $sorted = $keys; + sort($sorted); + + $this->assertSame($sorted, $keys); + } +}