- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
736 lines
28 KiB
PHP
736 lines
28 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\LiveComponents;
|
|
|
|
use App\Framework\Core\Events\EventDispatcherInterface;
|
|
use App\Framework\Http\Session\SessionInterface;
|
|
use App\Framework\Http\UploadedFile;
|
|
use App\Framework\Idempotency\IdempotencyService;
|
|
use App\Framework\LiveComponents\Attributes\Action;
|
|
use App\Framework\LiveComponents\Attributes\RequiresPermission;
|
|
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
|
use App\Framework\LiveComponents\Contracts\SupportsFileUpload;
|
|
use App\Framework\LiveComponents\Events\ComponentUpdatedEvent;
|
|
use App\Framework\LiveComponents\Exceptions\RateLimitExceededException;
|
|
use App\Framework\LiveComponents\Exceptions\StateValidationException;
|
|
use App\Framework\LiveComponents\Exceptions\UnauthorizedActionException;
|
|
use App\Framework\LiveComponents\ParameterBinding\ParameterBinder;
|
|
use App\Framework\LiveComponents\Security\ActionAuthorizationChecker;
|
|
use App\Framework\LiveComponents\Services\LiveComponentRateLimiter;
|
|
use App\Framework\LiveComponents\Validation\DefaultStateValidator;
|
|
use App\Framework\LiveComponents\Validation\DerivedSchema;
|
|
use App\Framework\LiveComponents\Validation\SchemaCache;
|
|
use App\Framework\LiveComponents\Validation\StateValidator;
|
|
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentData;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
|
|
use App\Framework\LiveComponents\ValueObjects\LiveComponentState;
|
|
use App\Framework\LiveComponents\ValueObjects\ReservedActionName;
|
|
use App\Framework\Performance\NestedPerformanceTracker;
|
|
use App\Framework\Performance\PerformanceCategory;
|
|
|
|
/**
|
|
* Handles LiveComponent action execution and state updates
|
|
*
|
|
* Composition over inheritance - this replaces the LiveComponentTrait.
|
|
* Components no longer need to implement handle() logic themselves.
|
|
*
|
|
* The handler manages:
|
|
* - Event dispatching
|
|
* - CSRF protection
|
|
* - Action allow-list validation (#[Action] attribute)
|
|
* - Authorization checks via #[RequiresPermission] attribute
|
|
* - Rate limiting per component/action
|
|
* - Idempotency protection for duplicate requests
|
|
* - State validation with automatic schema derivation
|
|
*/
|
|
final readonly class LiveComponentHandler
|
|
{
|
|
private StateValidator $stateValidator;
|
|
|
|
public function __construct(
|
|
private ComponentEventDispatcher $eventDispatcher,
|
|
private SessionInterface $session,
|
|
private ActionAuthorizationChecker $authorizationChecker,
|
|
private SchemaCache $schemaCache,
|
|
private LiveComponentRateLimiter $rateLimiter,
|
|
private IdempotencyService $idempotency,
|
|
private ParameterBinder $parameterBinder,
|
|
private EventDispatcherInterface $frameworkEventDispatcher,
|
|
private NestedPerformanceTracker $performanceTracker
|
|
) {
|
|
$this->stateValidator = new DefaultStateValidator();
|
|
}
|
|
|
|
/**
|
|
* Handle component action
|
|
*
|
|
* @param LiveComponentContract $component The component instance
|
|
* @param string $method The action method to call
|
|
* @param ActionParameters $params Parameters for the action
|
|
* @return ComponentUpdate Update with new state and events (HTML will be filled by controller)
|
|
* @throws \RuntimeException if CSRF validation fails
|
|
* @throws \BadMethodCallException if method doesn't exist
|
|
* @throws UnauthorizedActionException if authorization check fails
|
|
* @throws StateValidationException if state validation fails
|
|
* @throws RateLimitExceededException if rate limit is exceeded
|
|
*/
|
|
public function handle(
|
|
LiveComponentContract $component,
|
|
string $method,
|
|
ActionParameters $params
|
|
): ComponentUpdate {
|
|
// 1. CSRF Protection - validate token before executing action
|
|
// Skip CSRF for 'poll' action as it's framework-initiated, not user-initiated
|
|
if ($method !== 'poll') {
|
|
$this->validateCsrf($component->id, $params);
|
|
}
|
|
|
|
// 2. Validate action is allowed (not reserved and has #[Action] attribute)
|
|
$actionAttribute = $this->validateAction($component, $method);
|
|
|
|
// 3. Idempotency Protection - check if this request has already been processed
|
|
if ($params->hasIdempotencyKey() && $actionAttribute?->idempotencyTTL !== null) {
|
|
return $this->handleWithIdempotency(
|
|
$component,
|
|
$method,
|
|
$params,
|
|
$actionAttribute
|
|
);
|
|
}
|
|
|
|
// 4. Rate Limiting - check if client has exceeded rate limits
|
|
$this->validateRateLimit($component, $method, $params, $actionAttribute);
|
|
|
|
// 5. Authorization Check - verify user has required permissions
|
|
$this->validateAuthorization($component, $method);
|
|
|
|
// 6. Execute action without idempotency
|
|
return $this->executeActionAndBuildUpdate($component, $method, $params);
|
|
}
|
|
|
|
/**
|
|
* Handle action with idempotency protection
|
|
*
|
|
* Uses IdempotencyService to cache results and prevent duplicate execution.
|
|
* If the idempotency key has been seen before, returns cached result.
|
|
*
|
|
* @param LiveComponentContract $component Component instance
|
|
* @param string $method Action method name
|
|
* @param ActionParameters $params Action parameters with idempotency key
|
|
* @param Action $actionAttribute Action attribute with TTL configuration
|
|
* @return ComponentUpdate Cached or fresh component update
|
|
*/
|
|
private function handleWithIdempotency(
|
|
LiveComponentContract $component,
|
|
string $method,
|
|
ActionParameters $params,
|
|
Action $actionAttribute
|
|
): ComponentUpdate {
|
|
$idempotencyKey = $params->getIdempotencyKey();
|
|
|
|
// Use IdempotencyService to execute with caching
|
|
return $this->idempotency->execute(
|
|
key: $idempotencyKey,
|
|
operation: function () use ($component, $method, $params, $actionAttribute): ComponentUpdate {
|
|
// Rate Limiting - check if client has exceeded rate limits
|
|
$this->validateRateLimit($component, $method, $params, $actionAttribute);
|
|
|
|
// Authorization Check - verify user has required permissions
|
|
$this->validateAuthorization($component, $method);
|
|
|
|
// Execute action and build update
|
|
return $this->executeActionAndBuildUpdate($component, $method, $params);
|
|
},
|
|
ttl: \App\Framework\Core\ValueObjects\Duration::fromSeconds($actionAttribute->idempotencyTTL)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Execute action and build component update
|
|
*
|
|
* Core action execution logic extracted for reuse in both
|
|
* idempotent and non-idempotent flows.
|
|
*
|
|
* @param LiveComponentContract $component Component instance
|
|
* @param string $method Action method name
|
|
* @param ActionParameters $params Action parameters
|
|
* @return ComponentUpdate Component update with new state and events
|
|
*/
|
|
private function executeActionAndBuildUpdate(
|
|
LiveComponentContract $component,
|
|
string $method,
|
|
ActionParameters $params
|
|
): ComponentUpdate {
|
|
return $this->performanceTracker->measure(
|
|
"livecomponent.{$component->id->name}.{$method}",
|
|
PerformanceCategory::CUSTOM,
|
|
function () use ($component, $method, $params): ComponentUpdate {
|
|
// 1. Derive or get cached schema
|
|
$schema = $this->performanceTracker->measure(
|
|
"livecomponent.schema.derive",
|
|
PerformanceCategory::CACHE,
|
|
fn() => $this->getOrDeriveSchema($component),
|
|
['component' => $component->id->name]
|
|
);
|
|
|
|
// 2. Clear any previous events
|
|
$this->eventDispatcher->clear();
|
|
|
|
// 3. Call action method with params and event dispatcher
|
|
// Components can optionally accept ComponentEventDispatcher as last parameter
|
|
$newData = $this->performanceTracker->measure(
|
|
"livecomponent.action.execute",
|
|
PerformanceCategory::CUSTOM,
|
|
fn() => $this->executeAction($component, $method, $params),
|
|
['action' => $method, 'component' => $component->id->name]
|
|
);
|
|
|
|
// 4. Extract component name from ID
|
|
$componentId = $component->id;
|
|
$componentName = $componentId->name;
|
|
|
|
// 5. Get dispatched events
|
|
$events = $this->eventDispatcher->getEvents();
|
|
|
|
// 6. Action methods now return State VOs directly (e.g., CounterState, SearchState)
|
|
// If action didn't return anything, get current state
|
|
$stateObject = $newData ?? $component->state;
|
|
|
|
// 7. Validate state against derived schema
|
|
$this->performanceTracker->measure(
|
|
"livecomponent.state.validate",
|
|
PerformanceCategory::CUSTOM,
|
|
fn() => $this->stateValidator->validateState($stateObject, $schema),
|
|
['component' => $component->id->name]
|
|
);
|
|
|
|
// 8. Call onUpdate() lifecycle hook if component implements LifecycleAware
|
|
$this->performanceTracker->measure(
|
|
"livecomponent.lifecycle.onUpdate",
|
|
PerformanceCategory::CUSTOM,
|
|
fn() => $this->callUpdateHook($component, $stateObject),
|
|
['component' => $component->id->name]
|
|
);
|
|
|
|
// 9. Convert State VO to array for serialization
|
|
$stateArray = $stateObject->toArray();
|
|
|
|
// 10. Build ComponentUpdate
|
|
// The LiveComponentController will render HTML using ComponentRegistry
|
|
$componentUpdate = new ComponentUpdate(
|
|
html: '', // Will be populated by controller
|
|
events: $events,
|
|
state: new LiveComponentState(
|
|
id: $componentId->toString(),
|
|
component: $componentName,
|
|
data: $stateArray
|
|
)
|
|
);
|
|
|
|
// 11. Dispatch domain event for SSE broadcasting
|
|
// This enables real-time updates via Server-Sent Events
|
|
$this->frameworkEventDispatcher->dispatch(
|
|
new ComponentUpdatedEvent(
|
|
componentId: $componentId,
|
|
state: $stateArray,
|
|
html: null, // HTML will be rendered by controller
|
|
events: $events
|
|
)
|
|
);
|
|
|
|
return $componentUpdate;
|
|
},
|
|
['component' => $component->id->name, 'action' => $method]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Execute action method with advanced parameter binding
|
|
*
|
|
* Uses ParameterBinder for:
|
|
* - Builtin type casting
|
|
* - DTO instantiation via constructor promotion
|
|
* - Framework service injection
|
|
* - Multiple naming conventions
|
|
*/
|
|
private function executeAction(
|
|
LiveComponentContract $component,
|
|
string $method,
|
|
ActionParameters $params
|
|
): mixed {
|
|
// Try reflection first for real methods with parameter analysis
|
|
try {
|
|
$reflection = new \ReflectionMethod($component, $method);
|
|
} catch (\ReflectionException $e) {
|
|
// Magic method via __call() - call directly with params array
|
|
// The component's __call() implementation handles parameter mapping
|
|
return $component->$method(...$params->toArray());
|
|
}
|
|
|
|
// Use ParameterBinder for advanced parameter binding
|
|
$args = $this->parameterBinder->bindParameters($reflection, $params);
|
|
|
|
// Call method with bound arguments
|
|
return $component->$method(...$args);
|
|
}
|
|
|
|
/**
|
|
* Handle file upload action
|
|
*
|
|
* Similar to handle() but specifically for file uploads.
|
|
* Calls handleUpload() on components that implement SupportsFileUpload.
|
|
*
|
|
* @param SupportsFileUpload $component The component that supports uploads
|
|
* @param UploadedFile $file The uploaded file
|
|
* @param ActionParameters $params Additional upload parameters
|
|
* @return ComponentUpdate Update with new state and events
|
|
* @throws \RuntimeException if CSRF validation fails
|
|
* @throws UnauthorizedActionException if authorization check fails
|
|
* @throws StateValidationException if state validation fails
|
|
*/
|
|
public function handleUpload(
|
|
SupportsFileUpload $component,
|
|
UploadedFile $file,
|
|
ActionParameters $params
|
|
): ComponentUpdate {
|
|
// 1. CSRF Protection - validate token before executing upload
|
|
$this->validateCsrf($component->id, $params);
|
|
|
|
// 2. Authorization Check - verify user has required permissions for upload
|
|
$this->validateAuthorization($component, 'handleUpload');
|
|
|
|
// 3. Derive or get cached schema
|
|
$schema = $this->getOrDeriveSchema($component);
|
|
|
|
// 4. Clear any previous events
|
|
$this->eventDispatcher->clear();
|
|
|
|
// Call handleUpload with file, params, and event dispatcher
|
|
$newData = $component->handleUpload($file, $params, $this->eventDispatcher);
|
|
|
|
// Extract component name from ID
|
|
$componentId = $component->id;
|
|
$componentName = $componentId->name;
|
|
|
|
// Get dispatched events
|
|
$events = $this->eventDispatcher->getEvents();
|
|
|
|
// handleUpload() now returns State VO directly
|
|
$stateObject = $newData ?? $component->state;
|
|
|
|
// 5. Validate state against derived schema
|
|
$this->stateValidator->validateState($stateObject, $schema);
|
|
|
|
// 6. Call onUpdate() lifecycle hook if component implements LifecycleAware
|
|
$this->callUpdateHook($component, $stateObject);
|
|
|
|
// 7. Convert State VO to array
|
|
$stateArray = $stateObject->toArray();
|
|
|
|
// 8. Build ComponentUpdate
|
|
$componentUpdate = new ComponentUpdate(
|
|
html: '', // Will be populated by controller
|
|
events: $events,
|
|
state: new LiveComponentState(
|
|
id: $componentId->toString(),
|
|
component: $componentName,
|
|
data: $stateArray
|
|
)
|
|
);
|
|
|
|
// 9. Dispatch domain event for SSE broadcasting
|
|
$this->frameworkEventDispatcher->dispatch(
|
|
new ComponentUpdatedEvent(
|
|
componentId: $componentId,
|
|
state: $stateArray,
|
|
html: null, // HTML will be rendered by controller
|
|
events: $events
|
|
)
|
|
);
|
|
|
|
return $componentUpdate;
|
|
}
|
|
|
|
/**
|
|
* Validate CSRF token for component action
|
|
*
|
|
* Uses component ID as form ID for CSRF validation.
|
|
* This ensures each component instance has its own CSRF protection.
|
|
*
|
|
* @param ComponentId $componentId Component identifier
|
|
* @param ActionParameters $params Action parameters containing CSRF token
|
|
* @throws \RuntimeException if CSRF validation fails
|
|
* @throws \InvalidArgumentException if CSRF token is missing or invalid
|
|
*/
|
|
private function validateCsrf(ComponentId $componentId, ActionParameters $params): void
|
|
{
|
|
// Check if CSRF token is present
|
|
if (! $params->hasCsrfToken()) {
|
|
throw new \InvalidArgumentException(
|
|
'CSRF token is required for LiveComponent actions'
|
|
);
|
|
}
|
|
|
|
$csrfToken = $params->getCsrfToken();
|
|
|
|
// Use component ID as form ID for CSRF validation
|
|
// This provides per-component-instance CSRF protection
|
|
$formId = 'livecomponent:' . $componentId->toString();
|
|
|
|
// Validate token using session's CSRF protection
|
|
if (! $this->session->csrf->validateToken($formId, $csrfToken)) {
|
|
throw new \RuntimeException(
|
|
'CSRF token validation failed for component: ' . $componentId->toString()
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate that an action is allowed to be called
|
|
*
|
|
* Checks:
|
|
* 1. Method is not reserved (framework methods, lifecycle hooks, magic methods)
|
|
* 2. Method exists on component
|
|
* 3. Method has #[Action] attribute (allow-list)
|
|
* 4. Method is public and non-static
|
|
*
|
|
* @return Action|null The Action attribute instance (for rate limit configuration)
|
|
* @throws \BadMethodCallException if action is invalid
|
|
*/
|
|
private function validateAction(LiveComponentContract $component, string $method): ?Action
|
|
{
|
|
// 1. Check if method is reserved
|
|
if (ReservedActionName::isReserved($method)) {
|
|
$reserved = ReservedActionName::tryFrom($method);
|
|
$reason = $reserved?->getReasonMessage() ?? 'Reserved method';
|
|
|
|
throw new \BadMethodCallException(
|
|
"Cannot call reserved method '{$method}' as action on " . get_class($component) . ". " .
|
|
"Reason: {$reason}. " .
|
|
"Available actions: " . implode(', ', $this->getAvailableActions($component))
|
|
);
|
|
}
|
|
|
|
// 2. Check if method exists
|
|
if (! method_exists($component, $method)) {
|
|
// Find similar action names for better error messages
|
|
$availableActions = $this->getAvailableActions($component);
|
|
$suggestions = $this->findSimilarActions($method, $availableActions);
|
|
|
|
$errorMessage = "Action '{$method}' not found on " . get_class($component) . ".";
|
|
|
|
if (! empty($suggestions)) {
|
|
$errorMessage .= " Did you mean: " . implode(', ', $suggestions) . "?";
|
|
}
|
|
|
|
if (! empty($availableActions)) {
|
|
$errorMessage .= " Available actions: " . implode(', ', $availableActions);
|
|
} else {
|
|
$errorMessage .= " Component has no available actions (no methods with #[Action] attribute).";
|
|
}
|
|
|
|
throw new \BadMethodCallException($errorMessage);
|
|
}
|
|
|
|
// 3. Check if method has #[Action] attribute
|
|
$reflection = new \ReflectionMethod($component, $method);
|
|
$actionAttributes = $reflection->getAttributes(Action::class);
|
|
|
|
if (empty($actionAttributes)) {
|
|
$availableActions = $this->getAvailableActions($component);
|
|
|
|
throw new \BadMethodCallException(
|
|
"Method '{$method}' on " . get_class($component) . " is not marked as an action. " .
|
|
"Add #[Action] attribute to make it callable from client. " .
|
|
"Available actions: " . implode(', ', $availableActions)
|
|
);
|
|
}
|
|
|
|
// 4. Check if method is public and non-static
|
|
if (! $reflection->isPublic()) {
|
|
throw new \BadMethodCallException(
|
|
"Action '{$method}' on " . get_class($component) . " must be public"
|
|
);
|
|
}
|
|
|
|
if ($reflection->isStatic()) {
|
|
throw new \BadMethodCallException(
|
|
"Action '{$method}' on " . get_class($component) . " cannot be static"
|
|
);
|
|
}
|
|
|
|
// 5. Validate return type is an object (State VO)
|
|
$returnType = $reflection->getReturnType();
|
|
|
|
if ($returnType instanceof \ReflectionNamedType) {
|
|
$typeName = $returnType->getName();
|
|
// Must return an object type (not primitive, not array)
|
|
if (in_array($typeName, ['void', 'null', 'int', 'float', 'string', 'bool', 'array'], true)) {
|
|
throw new \BadMethodCallException(
|
|
"Action '{$method}' on " . get_class($component) . " must return a State object (e.g., CounterState, SearchState), not {$typeName}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Return the Action attribute instance for rate limit configuration
|
|
return $actionAttributes[0]->newInstance();
|
|
}
|
|
|
|
/**
|
|
* Get list of available actions (methods with #[Action] attribute)
|
|
*
|
|
* @return array<string>
|
|
*/
|
|
private function getAvailableActions(LiveComponentContract $component): array
|
|
{
|
|
$reflection = new \ReflectionClass($component);
|
|
$actions = [];
|
|
|
|
foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
|
|
// Skip static methods
|
|
if ($method->isStatic()) {
|
|
continue;
|
|
}
|
|
|
|
// Skip reserved methods
|
|
if (ReservedActionName::isReserved($method->getName())) {
|
|
continue;
|
|
}
|
|
|
|
// Check for #[Action] attribute
|
|
$actionAttributes = $method->getAttributes(Action::class);
|
|
|
|
if (! empty($actionAttributes)) {
|
|
$actions[] = $method->getName();
|
|
}
|
|
}
|
|
|
|
return $actions;
|
|
}
|
|
|
|
/**
|
|
* Find similar action names using Levenshtein distance
|
|
*
|
|
* @param string $searchAction Action user tried to call
|
|
* @param array<string> $availableActions Available actions on component
|
|
* @return array<string> Similar action names (distance <= 3)
|
|
*/
|
|
private function findSimilarActions(string $searchAction, array $availableActions): array
|
|
{
|
|
$suggestions = [];
|
|
|
|
foreach ($availableActions as $actionName) {
|
|
$distance = levenshtein(
|
|
strtolower($searchAction),
|
|
strtolower($actionName)
|
|
);
|
|
|
|
// Consider it a suggestion if distance is small
|
|
if ($distance <= 3) {
|
|
$suggestions[] = $actionName;
|
|
}
|
|
}
|
|
|
|
return $suggestions;
|
|
}
|
|
|
|
/**
|
|
* Validate authorization for component action
|
|
*
|
|
* Checks if user has required permissions via #[RequiresPermission] attribute.
|
|
* If no attribute is present, authorization is not required.
|
|
*
|
|
* @param LiveComponentContract $component Component instance
|
|
* @param string $method Action method name
|
|
* @throws UnauthorizedActionException if authorization check fails
|
|
*/
|
|
private function validateAuthorization(LiveComponentContract $component, string $method): void
|
|
{
|
|
// Get RequiresPermission attribute from method
|
|
$permissionAttribute = $this->getRequiresPermissionAttribute($component, $method);
|
|
|
|
// No permission requirement → allow access
|
|
if ($permissionAttribute === null) {
|
|
return;
|
|
}
|
|
|
|
// Check authorization
|
|
$isAuthorized = $this->authorizationChecker->isAuthorized(
|
|
$component,
|
|
$method,
|
|
$permissionAttribute
|
|
);
|
|
|
|
if (! $isAuthorized) {
|
|
$componentName = $component->id->name;
|
|
$userPermissions = $this->authorizationChecker->getUserPermissions();
|
|
|
|
// Check if it's an authentication issue (user not logged in)
|
|
if (! $this->authorizationChecker->isAuthenticated()) {
|
|
throw UnauthorizedActionException::forUnauthenticatedUser(
|
|
$componentName,
|
|
$method
|
|
);
|
|
}
|
|
|
|
// It's an authorization issue (user lacks permission)
|
|
throw UnauthorizedActionException::forMissingPermission(
|
|
$componentName,
|
|
$method,
|
|
$permissionAttribute,
|
|
$userPermissions
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate rate limiting for component action
|
|
*
|
|
* Checks if client has exceeded rate limits for this action.
|
|
* Uses #[Action] attribute configuration or falls back to defaults.
|
|
*
|
|
* @param LiveComponentContract $component Component instance
|
|
* @param string $method Action method name
|
|
* @param ActionParameters $params Action parameters (contains client identifier)
|
|
* @param Action|null $actionAttribute Action attribute for rate limit configuration
|
|
* @throws RateLimitExceededException if rate limit is exceeded
|
|
*/
|
|
private function validateRateLimit(
|
|
LiveComponentContract $component,
|
|
string $method,
|
|
ActionParameters $params,
|
|
?Action $actionAttribute
|
|
): void {
|
|
// Skip if no client identifier
|
|
if (! $params->hasClientIdentifier()) {
|
|
return;
|
|
}
|
|
|
|
$clientIdentifier = $params->getClientIdentifier();
|
|
|
|
// Check rate limit
|
|
$result = $this->rateLimiter->checkActionLimit(
|
|
$component,
|
|
$method,
|
|
$clientIdentifier,
|
|
$actionAttribute
|
|
);
|
|
|
|
// Throw exception if exceeded
|
|
if ($result->isExceeded()) {
|
|
$componentName = $component->id->name;
|
|
|
|
throw RateLimitExceededException::forAction(
|
|
componentName: $componentName,
|
|
action: $method,
|
|
limit: $result->getLimit(),
|
|
current: $result->getCurrent(),
|
|
retryAfter: $result->getRetryAfter() ?? 60
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get RequiresPermission attribute from action method
|
|
*
|
|
* @return RequiresPermission|null Permission attribute if present
|
|
*/
|
|
private function getRequiresPermissionAttribute(
|
|
LiveComponentContract $component,
|
|
string $method
|
|
): ?RequiresPermission {
|
|
try {
|
|
$reflection = new \ReflectionMethod($component, $method);
|
|
$attributes = $reflection->getAttributes(RequiresPermission::class);
|
|
|
|
if (empty($attributes)) {
|
|
return null;
|
|
}
|
|
|
|
return $attributes[0]->newInstance();
|
|
} catch (\ReflectionException $e) {
|
|
// Method doesn't exist or is not accessible
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get or derive schema for component
|
|
*
|
|
* Uses SchemaCache for performance - derives schema once per component class.
|
|
* Schema is derived from first getState() call and cached.
|
|
*
|
|
* @param LiveComponentContract $component Component to get schema for
|
|
* @return DerivedSchema Cached or newly derived schema
|
|
*/
|
|
private function getOrDeriveSchema(LiveComponentContract $component): DerivedSchema
|
|
{
|
|
$componentClass = get_class($component);
|
|
|
|
// Check cache
|
|
$cachedSchema = $this->schemaCache->get($componentClass);
|
|
if ($cachedSchema !== null) {
|
|
return $cachedSchema;
|
|
}
|
|
|
|
// Derive schema from current component state
|
|
$currentState = $component->state;
|
|
$schema = $this->stateValidator->deriveSchemaFromState($currentState);
|
|
|
|
// Cache for future use
|
|
$this->schemaCache->set($componentClass, $schema);
|
|
|
|
return $schema;
|
|
}
|
|
|
|
/**
|
|
* Call onUpdate() lifecycle hook if component implements LifecycleAware
|
|
*/
|
|
private function callUpdateHook(
|
|
LiveComponentContract $component,
|
|
object $newState
|
|
): void {
|
|
if (! $component instanceof \App\Framework\LiveComponents\Contracts\LifecycleAware) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$component->onUpdate();
|
|
} catch (\Throwable $e) {
|
|
// Log lifecycle hook errors but don't fail the action
|
|
// Components should handle their own errors in hooks
|
|
error_log("Lifecycle hook onUpdate() failed for " . get_class($component) . ": " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call onMount() lifecycle hook if component implements LifecycleAware
|
|
*
|
|
* This should be called by ComponentRegistry after first component creation.
|
|
*/
|
|
public function callMountHook(LiveComponentContract $component): void
|
|
{
|
|
if (! $component instanceof \App\Framework\LiveComponents\Contracts\LifecycleAware) {
|
|
return;
|
|
}
|
|
|
|
$this->performanceTracker->measure(
|
|
"livecomponent.lifecycle.onMount",
|
|
PerformanceCategory::CUSTOM,
|
|
function () use ($component): void {
|
|
try {
|
|
$component->onMount();
|
|
} catch (\Throwable $e) {
|
|
// Log lifecycle hook errors but don't fail the creation
|
|
error_log("Lifecycle hook onMount() failed for " . get_class($component) . ": " . $e->getMessage());
|
|
}
|
|
},
|
|
['component' => $component->id->name]
|
|
);
|
|
}
|
|
}
|