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

This commit is contained in:
Fabian @ Blax Software 2026-04-29 09:26:53 +02:00
parent be3c7400fe
commit f030ff1fbf
5 changed files with 359 additions and 1 deletions

View File

@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Routing;
use BlaxSoftware\LaravelWebSockets\Websocket\EventRegistry;
use Illuminate\Routing\Router;
/**
* Registers every websocket event as a Laravel route with method `WS|WSS`
* so the full WS surface shows up in `php artisan route:list` next to the
* regular HTTP endpoints.
*
* The routes are inert for HTTP dispatch Laravel's router will never
* match a real HTTP request against the `WS`/`WSS` methods but they
* appear in `route:list`, `route:cache`, and similar tooling, which is
* the whole point: a developer wanting to enumerate the realtime API no
* longer has to grep `App\Websocket\Controllers\` by hand.
*
* Two sources are merged into one list:
* 1. Legacy WS controllers under `App\Websocket\Controllers\`. Every
* public, declared, non-lifecycle method becomes one event using
* the same kebab-prefix algorithm `ControllerResolver` resolves.
* 2. Attribute-tagged HTTP controller methods (`#[Websocket]`) via
* {@see EventRegistry}.
*
* Conflicts between the two sources are resolved with the HTTP-attribute
* winning (last write), since the attribute is the explicit declaration
* by the developer.
*/
class RouteListInjector
{
/** Methods on the package's WS Controller base class that aren't real events. */
private const LIFECYCLE_METHODS = [
'boot', 'booted', 'unboot',
'error', 'success',
'getConnection', 'getChannel', 'getEvent', 'getChannelManager',
'controll_message', 'handle',
'send_error',
];
/**
* Register every discovered WS event as a `WS|WSS` route on $router.
*/
public static function inject(Router $router): void
{
foreach (self::collect() as $event => $target) {
$router->addRoute(
['WS', 'WSS'],
$event,
['uses' => $target['class'] . '@' . $target['method']]
);
}
}
/**
* Build a sorted event-name target map merged from both sources.
*
* @return array<string, array{class: class-string, method: string, source: string, needAuth: bool}>
*/
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<class-string, array<int, string>>
*/
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;
}
}

View File

@ -34,6 +34,25 @@ class WebSocketsServiceProvider extends ServiceProvider
$this->registerIdentityFormatter(); $this->registerIdentityFormatter();
$this->registerBroadcastAuthRoute(); $this->registerBroadcastAuthRoute();
$this->registerCommands(); $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() public function register()

View File

@ -80,7 +80,13 @@ class Controller
// #[Websocket] attribute may handle this event. The registry // #[Websocket] attribute may handle this event. The registry
// exposes a flat event-name → callable map built by // exposes a flat event-name → callable map built by
// reflecting attribute-tagged methods at scan time. // 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) { if ($target) {
return self::dispatchHttpAttributeTarget($connection, $message, $target); return self::dispatchHttpAttributeTarget($connection, $message, $target);
} }

View File

@ -104,6 +104,23 @@ class DispatcherFallbackTest extends TestCase
$this->assertSame('test-channel', $result['sent']['channel']); $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 */ /** @test */
public function it_falls_through_to_the_send_error_when_neither_resolver_nor_registry_matches() public function it_falls_through_to_the_send_error_when_neither_resolver_nor_registry_matches()
{ {

View File

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Tests\Unit;
use BlaxSoftware\LaravelWebSockets\Routing\RouteListInjector;
use BlaxSoftware\LaravelWebSockets\Test\TestCase;
use BlaxSoftware\LaravelWebSockets\Websocket\EventRegistry;
use Illuminate\Routing\Router;
class RouteListInjectorTest extends TestCase
{
private const FIXTURES_NS = 'BlaxSoftware\\LaravelWebSockets\\Test\\Unit\\Fixtures\\Controllers';
public function setUp(): void
{
parent::setUp();
// Pin the registry to the same fixtures used by EventRegistryTest so
// we have a deterministic set of attribute-tagged events to merge in.
EventRegistry::clear();
EventRegistry::setSearchPaths([
__DIR__ . '/Fixtures/Controllers' => 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);
}
}