A hotreload

This commit is contained in:
Fabian @ Blax Software 2026-01-26 10:51:13 +01:00
parent c76e6ae111
commit 935bfb28d3
2 changed files with 136 additions and 1 deletions

View File

@ -2,6 +2,18 @@
return [
/*
|--------------------------------------------------------------------------
| Hot Reload (Development Mode)
|--------------------------------------------------------------------------
|
| When enabled, controller files are reloaded on every request instead of
| being cached. This allows you to make code changes without restarting
| the WebSocket server. Disable in production for better performance.
|
*/
'hot_reload' => env('WEBSOCKET_HOT_RELOAD', env('APP_DEBUG', false)),
/*
|--------------------------------------------------------------------------
| Dashboard Settings

View File

@ -12,6 +12,7 @@ namespace BlaxSoftware\LaravelWebSockets\Websocket;
* - Kebab-case: `admin-user.method` `AdminUserController`
* - Folder structure: `admin-user.method` `Admin/UserController` (fuzzy)
* - Dynamic discovery: new controllers are found and cached at runtime
* - Hot reload: disable caching in dev mode for instant code updates
*/
class ControllerResolver
{
@ -36,6 +37,19 @@ class ControllerResolver
*/
private static bool $scanned = false;
/**
* Hot reload mode - when enabled, caching is disabled for development
*/
private static ?bool $hotReload = null;
/**
* Track when classes were loaded to detect stale code
* Maps class name => file mtime at load time
*
* @var array<string, int>
*/
private static array $classLoadTimes = [];
/**
* App controller namespace
*/
@ -46,6 +60,17 @@ class ControllerResolver
*/
private const VENDOR_NAMESPACE = '\\BlaxSoftware\\LaravelWebSockets\\Websocket\\Controllers\\';
/**
* Check if hot reload mode is enabled
*/
private static function isHotReload(): bool
{
if (self::$hotReload === null) {
self::$hotReload = (bool) config('websockets.hot_reload', false);
}
return self::$hotReload;
}
/**
* Resolve controller class for an event prefix
*
@ -54,6 +79,11 @@ class ControllerResolver
*/
public static function resolve(string $eventPrefix): ?string
{
// In hot reload mode, skip cache and invalidate opcache for fresh code
if (self::isHotReload()) {
return self::resolveWithHotReload($eventPrefix);
}
// Check cache first (O(1) lookup)
if (array_key_exists($eventPrefix, self::$controllerCache)) {
return self::$controllerCache[$eventPrefix];
@ -68,6 +98,97 @@ class ControllerResolver
return $controllerClass;
}
/**
* Resolve controller with hot reload - invalidates opcache for fresh code
* This is slower but allows code changes without server restart
*/
private static function resolveWithHotReload(string $eventPrefix): ?string
{
$directName = self::kebabToPascal($eventPrefix) . 'Controller';
// Try app namespace first
$appClass = self::APP_NAMESPACE . $directName;
$appFile = self::getControllerFilePath($appClass);
if ($appFile && file_exists($appFile)) {
self::invalidateAndReload($appFile);
if (class_exists($appClass, true)) {
return $appClass;
}
}
// Try vendor namespace
$vendorClass = self::VENDOR_NAMESPACE . $directName;
$vendorFile = self::getControllerFilePath($vendorClass);
if ($vendorFile && file_exists($vendorFile)) {
self::invalidateAndReload($vendorFile);
if (class_exists($vendorClass, true)) {
return $vendorClass;
}
}
// Try folder structure for kebab-case names
$parts = explode('-', $eventPrefix);
if (count($parts) > 1) {
for ($folderDepth = count($parts) - 1; $folderDepth >= 1; $folderDepth--) {
$folderParts = array_slice($parts, 0, $folderDepth);
$nameParts = array_slice($parts, $folderDepth);
$folder = implode('/', array_map('ucfirst', $folderParts));
$name = implode('', array_map('ucfirst', $nameParts)) . 'Controller';
// Try app namespace with folder
$appClass = self::APP_NAMESPACE . str_replace('/', '\\', $folder) . '\\' . $name;
$appFile = self::getControllerFilePath($appClass);
if ($appFile && file_exists($appFile)) {
self::invalidateAndReload($appFile);
if (class_exists($appClass, true)) {
return $appClass;
}
}
}
}
return null;
}
/**
* Invalidate opcache for a file and force reload
*/
private static function invalidateAndReload(string $filePath): void
{
if (function_exists('opcache_invalidate')) {
opcache_invalidate($filePath, true);
}
}
/**
* Get the file path for a controller class
*/
private static function getControllerFilePath(string $className): ?string
{
// For App namespace
if (str_starts_with($className, self::APP_NAMESPACE)) {
$relativePath = str_replace(self::APP_NAMESPACE, '', $className);
$relativePath = str_replace('\\', '/', $relativePath);
$appPath = self::getAppControllersPath();
if ($appPath) {
return $appPath . '/' . $relativePath . '.php';
}
}
// For vendor namespace
if (str_starts_with($className, self::VENDOR_NAMESPACE)) {
$relativePath = str_replace(self::VENDOR_NAMESPACE, '', $className);
$relativePath = str_replace('\\', '/', $relativePath);
return __DIR__ . '/Controllers/' . $relativePath . '.php';
}
return null;
}
/**
* Find controller using multiple strategies
* Optimized for speed: most common case (direct match) checked first
@ -254,12 +375,13 @@ class ControllerResolver
self::$controllerCache = [];
self::$availableControllers = [];
self::$scanned = false;
self::$hotReload = null;
}
/**
* Get cache statistics (for debugging)
*
* @return array{cached: int, available: int, scanned: bool}
* @return array{cached: int, available: int, scanned: bool, hot_reload: bool}
*/
public static function getStats(): array
{
@ -267,6 +389,7 @@ class ControllerResolver
'cached' => count(self::$controllerCache),
'available' => count(self::$availableControllers),
'scanned' => self::$scanned,
'hot_reload' => self::isHotReload(),
];
}