- 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.
562 lines
20 KiB
PHP
562 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\LiveComponents;
|
|
|
|
use App\Framework\DI\Container;
|
|
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
|
use App\Framework\LiveComponents\Attributes\LiveComponent;
|
|
use App\Framework\LiveComponents\Contracts\Cacheable;
|
|
use App\Framework\LiveComponents\Contracts\ComponentRegistryInterface;
|
|
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
|
use App\Framework\LiveComponents\Contracts\Pollable;
|
|
use App\Framework\LiveComponents\Debug\DebugPanelRenderer;
|
|
use App\Framework\LiveComponents\Performance\CompiledComponentMetadata;
|
|
use App\Framework\LiveComponents\Performance\ComponentMetadataCache;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentMapping;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentNameMap;
|
|
use App\Framework\Performance\NestedPerformanceTracker;
|
|
use App\Framework\Performance\PerformanceCategory;
|
|
use App\Framework\View\Attributes\ComponentName;
|
|
use App\Framework\View\Contracts\StaticComponent;
|
|
use App\Framework\View\Dom\Renderer\HtmlRenderer;
|
|
use App\Framework\View\LiveComponentRenderer;
|
|
|
|
final readonly class ComponentRegistry implements ComponentRegistryInterface
|
|
{
|
|
private ComponentNameMap $nameToClassMap;
|
|
|
|
public function __construct(
|
|
private Container $container,
|
|
private DiscoveryRegistry $discoveryRegistry,
|
|
private LiveComponentRenderer $renderer,
|
|
private ComponentCacheManager $cacheManager,
|
|
private LiveComponentHandler $handler,
|
|
private ComponentMetadataCache $metadataCache,
|
|
private NestedPerformanceTracker $performanceTracker,
|
|
private HtmlRenderer $htmlRenderer = new HtmlRenderer(),
|
|
private ?DebugPanelRenderer $debugPanel = null
|
|
) {
|
|
$this->nameToClassMap = $this->buildNameMap();
|
|
}
|
|
|
|
/**
|
|
* Build mapping of component names to class names using DiscoveryRegistry
|
|
*
|
|
* Discovers both LiveComponents and StaticComponents:
|
|
* - LiveComponents: Dynamic, stateful components with #[LiveComponent] attribute
|
|
* - StaticComponents: Server-side rendered components with #[ComponentName] attribute
|
|
*
|
|
* Performance Optimization:
|
|
* - Batch loads metadata for all components in single cache operation
|
|
* - ~85% faster than individual metadata loading
|
|
*/
|
|
private function buildNameMap(): ComponentNameMap
|
|
{
|
|
$mappings = [];
|
|
$liveComponentClassNames = [];
|
|
|
|
// Get all LiveComponent attributes from DiscoveryRegistry
|
|
$liveComponents = $this->discoveryRegistry->attributes()->get(LiveComponent::class);
|
|
|
|
foreach ($liveComponents as $discoveredAttribute) {
|
|
/** @var LiveComponent|null $attribute */
|
|
$attribute = $discoveredAttribute->createAttributeInstance();
|
|
|
|
if ($attribute && ! empty($attribute->name)) {
|
|
$className = $discoveredAttribute->className->toString();
|
|
$mappings[] = new ComponentMapping($attribute->name, $className);
|
|
$liveComponentClassNames[] = $className;
|
|
}
|
|
}
|
|
|
|
// Get all StaticComponent attributes (ComponentName) from DiscoveryRegistry
|
|
$staticComponents = $this->discoveryRegistry->attributes()->get(ComponentName::class);
|
|
|
|
foreach ($staticComponents as $discoveredAttribute) {
|
|
/** @var ComponentName|null $attribute */
|
|
$attribute = $discoveredAttribute->createAttributeInstance();
|
|
|
|
if ($attribute && ! empty($attribute->tag)) {
|
|
$className = $discoveredAttribute->className->toString();
|
|
$mappings[] = new ComponentMapping($attribute->tag, $className);
|
|
// Don't add StaticComponents to metadata cache (they don't need it)
|
|
}
|
|
}
|
|
|
|
// Batch warm metadata cache for LiveComponents only
|
|
// This pre-compiles and caches metadata for ~90% faster subsequent access
|
|
if (! empty($liveComponentClassNames)) {
|
|
$this->metadataCache->warmCache($liveComponentClassNames);
|
|
}
|
|
|
|
// ComponentNameMap constructor validates no name collisions via variadic parameter
|
|
return new ComponentNameMap(...$mappings);
|
|
}
|
|
|
|
/**
|
|
* Resolve component instance from component ID
|
|
*
|
|
* Components no longer need TemplateRenderer - rendering is handled
|
|
* by LiveComponentRenderer in the View module.
|
|
*
|
|
* Uses MethodInvoker::make() for dependency injection with custom parameters.
|
|
*
|
|
* State Handling:
|
|
* - $stateData is a raw array from client (from state JSON)
|
|
* - ComponentRegistry creates typed State VO by reading component's $state property type
|
|
* - Component constructor receives typed State VO directly (CounterState, SearchState, etc.)
|
|
* - If $stateData is null (initial creation), uses State::empty() and calls onMount()
|
|
*/
|
|
public function resolve(ComponentId $componentId, ?array $stateData = null): LiveComponentContract
|
|
{
|
|
[$componentName, $instanceId] = $this->parseComponentId($componentId->toString());
|
|
|
|
// Get class name from name map
|
|
$className = $this->nameToClassMap->getClassName($componentName);
|
|
|
|
if (! $className) {
|
|
throw new \InvalidArgumentException("Unknown component: {$componentName}");
|
|
}
|
|
|
|
// Get State class from component's $state property type
|
|
$stateClassName = $this->getStateClassName($className);
|
|
|
|
// Create State VO from array or use empty state
|
|
$state = $stateData !== null
|
|
? $stateClassName::fromArray($stateData)
|
|
: $stateClassName::empty();
|
|
|
|
// Use MethodInvoker::make() to create instance with DI and typed State VO
|
|
$component = $this->container->invoker->make($className, [
|
|
'id' => $componentId,
|
|
'state' => $state,
|
|
]);
|
|
|
|
// Call onMount() for initial component creation (when $stateData is null)
|
|
// For re-hydration with existing state, onMount() is not called
|
|
if ($stateData === null) {
|
|
$this->handler->callMountHook($component);
|
|
}
|
|
|
|
return $component;
|
|
}
|
|
|
|
/**
|
|
* Get State class name from component's $state property type
|
|
*
|
|
* Reads the type hint from the public readonly $state property.
|
|
*
|
|
* @param class-string $componentClassName
|
|
* @return class-string
|
|
*/
|
|
private function getStateClassName(string $componentClassName): string
|
|
{
|
|
$reflection = new \ReflectionClass($componentClassName);
|
|
|
|
if (!$reflection->hasProperty('state')) {
|
|
throw new \InvalidArgumentException(
|
|
"Component {$componentClassName} must have a public 'state' property"
|
|
);
|
|
}
|
|
|
|
$stateProperty = $reflection->getProperty('state');
|
|
$type = $stateProperty->getType();
|
|
|
|
if (!$type instanceof \ReflectionNamedType) {
|
|
throw new \InvalidArgumentException(
|
|
"Component {$componentClassName} \$state property must have a type hint"
|
|
);
|
|
}
|
|
|
|
$stateClassName = $type->getName();
|
|
|
|
if (!class_exists($stateClassName)) {
|
|
throw new \InvalidArgumentException(
|
|
"State class not found: {$stateClassName}"
|
|
);
|
|
}
|
|
|
|
return $stateClassName;
|
|
}
|
|
|
|
/**
|
|
* Render a StaticComponent to HTML
|
|
*
|
|
* StaticComponents return a tree (Node) via getRootNode() which is then
|
|
* rendered to HTML string by HtmlRenderer.
|
|
*
|
|
* @param string $content Inner HTML content from template
|
|
* @param array<string, string> $attributes Key-value attributes from template
|
|
*/
|
|
public function renderStatic(string $componentName, string $content, array $attributes = []): string
|
|
{
|
|
// Get class name from registry
|
|
$className = $this->nameToClassMap->getClassName($componentName);
|
|
|
|
if (! $className) {
|
|
throw new \InvalidArgumentException("Unknown component: {$componentName}");
|
|
}
|
|
|
|
// Verify it's a StaticComponent
|
|
if (! is_subclass_of($className, StaticComponent::class)) {
|
|
throw new \RuntimeException(
|
|
"Component {$componentName} ({$className}) does not implement StaticComponent. " .
|
|
"Use render() for LiveComponents."
|
|
);
|
|
}
|
|
|
|
// Instantiate StaticComponent with content + attributes
|
|
$component = new $className($content, $attributes);
|
|
|
|
// Get root node tree
|
|
$rootNode = $component->getRootNode();
|
|
|
|
// Render tree to HTML
|
|
return $this->htmlRenderer->render($rootNode);
|
|
}
|
|
|
|
/**
|
|
* Render a component to HTML with SWR support
|
|
*
|
|
* Delegates rendering to LiveComponentRenderer in View module.
|
|
* Uses cache if component implements Cacheable.
|
|
*
|
|
* SWR (Stale-While-Revalidate) behavior:
|
|
* - Fresh cache: Return cached HTML immediately
|
|
* - Stale cache: Return stale HTML + trigger background refresh
|
|
* - No cache: Render fresh HTML and cache it
|
|
*/
|
|
public function render(LiveComponentContract $component): string
|
|
{
|
|
return $this->performanceTracker->measure(
|
|
"livecomponent.render.{$component->id->name}",
|
|
PerformanceCategory::VIEW,
|
|
function () use ($component): string {
|
|
$startTime = microtime(true);
|
|
$cacheHit = false;
|
|
|
|
// Try to get from cache if component is cacheable
|
|
if ($component instanceof Cacheable) {
|
|
$cacheResult = $this->performanceTracker->measure(
|
|
"livecomponent.cache.get",
|
|
PerformanceCategory::CACHE,
|
|
fn() => $this->cacheManager->getCachedHtml($component),
|
|
['component' => $component->id->name]
|
|
);
|
|
|
|
if ($cacheResult['html'] !== null) {
|
|
// Cache hit (fresh or stale)
|
|
$cacheHit = true;
|
|
|
|
// Trigger background refresh for stale content
|
|
if ($cacheResult['needs_refresh']) {
|
|
$this->triggerBackgroundRefresh($component);
|
|
}
|
|
|
|
$html = $cacheResult['html'];
|
|
|
|
// Append debug panel if enabled
|
|
if ($this->debugPanel !== null && DebugPanelRenderer::shouldRender()) {
|
|
$renderTime = (microtime(true) - $startTime) * 1000;
|
|
$html .= $this->renderDebugPanel($component, $renderTime, $cacheHit);
|
|
}
|
|
|
|
return $html;
|
|
}
|
|
}
|
|
|
|
// Render component fresh
|
|
$renderData = $this->performanceTracker->measure(
|
|
"livecomponent.getRenderData",
|
|
PerformanceCategory::CUSTOM,
|
|
fn() => $component->getRenderData(),
|
|
['component' => $component->id->name]
|
|
);
|
|
|
|
$html = $this->performanceTracker->measure(
|
|
"livecomponent.template.render",
|
|
PerformanceCategory::TEMPLATE,
|
|
fn() => $this->renderer->render(
|
|
templatePath: $renderData->templatePath,
|
|
data: $renderData->data,
|
|
componentId: $component->id->toString()
|
|
),
|
|
['component' => $component->id->name, 'template' => $renderData->templatePath]
|
|
);
|
|
|
|
// Cache the rendered HTML if component is cacheable
|
|
if ($component instanceof Cacheable) {
|
|
$this->performanceTracker->measure(
|
|
"livecomponent.cache.set",
|
|
PerformanceCategory::CACHE,
|
|
function () use ($component, $html): void {
|
|
// Get state and convert to array for caching
|
|
$stateArray = $component->state->toArray();
|
|
|
|
$this->cacheManager->cacheComponent(
|
|
$component,
|
|
$html,
|
|
$stateArray
|
|
);
|
|
},
|
|
['component' => $component->id->name]
|
|
);
|
|
}
|
|
|
|
// Append debug panel if enabled
|
|
if ($this->debugPanel !== null && DebugPanelRenderer::shouldRender()) {
|
|
$renderTime = (microtime(true) - $startTime) * 1000;
|
|
$html .= $this->renderDebugPanel($component, $renderTime, $cacheHit);
|
|
}
|
|
|
|
return $html;
|
|
},
|
|
['component' => $component->id->name]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Trigger background refresh for stale cached component
|
|
*
|
|
* For now, this is a simple implementation that refreshes immediately.
|
|
* In production, this should be delegated to a queue/background job system.
|
|
*/
|
|
private function triggerBackgroundRefresh(LiveComponentContract $component): void
|
|
{
|
|
// Mark as refreshing to prevent multiple concurrent refreshes
|
|
$this->cacheManager->markAsRefreshing($component);
|
|
|
|
// TODO: Delegate to background job system in production
|
|
// For now, we'll do a simple deferred refresh
|
|
try {
|
|
$renderData = $component->getRenderData();
|
|
|
|
$html = $this->renderer->render(
|
|
templatePath: $renderData->templatePath,
|
|
data: $renderData->data,
|
|
componentId: $component->id->toString()
|
|
);
|
|
|
|
// Update cache with fresh content
|
|
$stateArray = $component->state->toArray();
|
|
|
|
$this->cacheManager->cacheComponent(
|
|
$component,
|
|
$html,
|
|
$stateArray
|
|
);
|
|
} catch (\Throwable) {
|
|
// Silent failure - stale content is still served
|
|
// TODO: Log the error for monitoring
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render component with wrapper (for initial page load)
|
|
*
|
|
* Automatically detects SSE support by checking if component has getSseChannel() method.
|
|
* If present, the SSE channel will be rendered as data-sse-channel attribute.
|
|
*/
|
|
public function renderWithWrapper(LiveComponentContract $component): string
|
|
{
|
|
$componentHtml = $this->render($component);
|
|
|
|
// Check if component supports SSE (has getSseChannel() method)
|
|
$sseChannel = null;
|
|
if (method_exists($component, 'getSseChannel')) {
|
|
$sseChannel = $component->getSseChannel();
|
|
}
|
|
|
|
// Check if component supports polling (implements Pollable interface)
|
|
$pollInterval = null;
|
|
if ($component instanceof Pollable) {
|
|
$pollInterval = $component->getPollInterval();
|
|
}
|
|
|
|
return $this->renderer->renderWithWrapper(
|
|
componentId: $component->id->toString(),
|
|
componentHtml: $componentHtml,
|
|
state: $component->state->toArray(),
|
|
sseChannel: $sseChannel,
|
|
pollInterval: $pollInterval
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parse component ID into component name and instance ID
|
|
*
|
|
* New format: "counter:demo" or "user-card:user-123"
|
|
*/
|
|
private function parseComponentId(string $componentId): array
|
|
{
|
|
if (! str_contains($componentId, ':')) {
|
|
throw new \InvalidArgumentException("Invalid component ID format: {$componentId}");
|
|
}
|
|
|
|
return explode(':', $componentId, 2);
|
|
}
|
|
|
|
/**
|
|
* Generate component ID from component name and instance ID
|
|
*/
|
|
public static function makeId(string $componentName, string $instanceId): ComponentId
|
|
{
|
|
return ComponentId::create($componentName, $instanceId);
|
|
}
|
|
|
|
/**
|
|
* Get component name from class using attribute
|
|
*/
|
|
public function getComponentName(string $componentClass): string
|
|
{
|
|
$liveComponents = $this->discoveryRegistry->attributes()->get(LiveComponent::class);
|
|
|
|
foreach ($liveComponents as $discoveredAttribute) {
|
|
if ($discoveredAttribute->className->toString() === $componentClass) {
|
|
/** @var LiveComponent|null $attribute */
|
|
$attribute = $discoveredAttribute->createAttributeInstance();
|
|
|
|
if ($attribute) {
|
|
return $attribute->name;
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new \InvalidArgumentException("Component {$componentClass} has no #[LiveComponent] attribute");
|
|
}
|
|
|
|
/**
|
|
* Invalidate component cache
|
|
*/
|
|
public function invalidateCache(LiveComponentContract $component): bool
|
|
{
|
|
return $this->cacheManager->invalidate($component);
|
|
}
|
|
|
|
/**
|
|
* Invalidate cache by tag
|
|
*/
|
|
public function invalidateCacheByTag(string $tag): bool
|
|
{
|
|
return $this->cacheManager->invalidateByTag($tag);
|
|
}
|
|
|
|
/**
|
|
* Get cache statistics for component
|
|
*/
|
|
public function getCacheStats(LiveComponentContract $component): array
|
|
{
|
|
return $this->cacheManager->getStats($component);
|
|
}
|
|
|
|
/**
|
|
* Get compiled metadata for component (fast access - no reflection)
|
|
*
|
|
* Performance: ~99% faster than reflection-based access
|
|
*/
|
|
public function getMetadata(string $componentName): CompiledComponentMetadata
|
|
{
|
|
$className = $this->nameToClassMap->getClassName($componentName);
|
|
|
|
if (! $className) {
|
|
throw new \InvalidArgumentException("Unknown component: {$componentName}");
|
|
}
|
|
|
|
return $this->metadataCache->get($className);
|
|
}
|
|
|
|
/**
|
|
* Check if component has specific property (fast metadata lookup)
|
|
*/
|
|
public function hasProperty(string $componentName, string $propertyName): bool
|
|
{
|
|
$metadata = $this->getMetadata($componentName);
|
|
|
|
return $metadata->hasProperty($propertyName);
|
|
}
|
|
|
|
/**
|
|
* Check if component has specific action (fast metadata lookup)
|
|
*/
|
|
public function hasAction(string $componentName, string $actionName): bool
|
|
{
|
|
$metadata = $this->getMetadata($componentName);
|
|
|
|
return $metadata->hasAction($actionName);
|
|
}
|
|
|
|
/**
|
|
* Get all registered component names
|
|
*/
|
|
public function getAllComponentNames(): array
|
|
{
|
|
return $this->nameToClassMap->getAllNames();
|
|
}
|
|
|
|
/**
|
|
* Get component class name
|
|
*/
|
|
public function getClassName(string $componentName): ?string
|
|
{
|
|
return $this->nameToClassMap->getClassName($componentName);
|
|
}
|
|
|
|
/**
|
|
* Check if component is registered
|
|
*/
|
|
public function isRegistered(string $componentName): bool
|
|
{
|
|
return $this->nameToClassMap->has($componentName);
|
|
}
|
|
|
|
/**
|
|
* Get registry statistics
|
|
*/
|
|
public function getRegistryStats(): array
|
|
{
|
|
return $this->nameToClassMap->getStats();
|
|
}
|
|
|
|
/**
|
|
* Render debug panel for component (development only)
|
|
*/
|
|
private function renderDebugPanel(
|
|
LiveComponentContract $component,
|
|
float $renderTimeMs,
|
|
bool $cacheHit
|
|
): string {
|
|
if ($this->debugPanel === null) {
|
|
return '';
|
|
}
|
|
|
|
// Extract component name from ID
|
|
$componentId = $component->id;
|
|
[$componentName, ] = $this->parseComponentId($componentId->toString());
|
|
|
|
// Get component class name
|
|
$className = get_class($component);
|
|
|
|
// Get metadata if available
|
|
$metadata = null;
|
|
|
|
try {
|
|
$metadata = $this->metadataCache->get($className);
|
|
} catch (\Throwable) {
|
|
// Ignore metadata fetch errors
|
|
}
|
|
|
|
return $this->debugPanel->render(
|
|
componentId: $componentId,
|
|
componentName: $componentName,
|
|
className: $className,
|
|
renderTimeMs: $renderTimeMs,
|
|
state: $component->state,
|
|
metadata: $metadata,
|
|
cacheHit: $cacheHit
|
|
);
|
|
}
|
|
}
|