- 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.
366 lines
13 KiB
PHP
366 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\LiveComponents\Controllers;
|
|
|
|
use App\Framework\Attributes\Route;
|
|
use App\Framework\Http\HttpRequest;
|
|
use App\Framework\Http\Method;
|
|
use App\Framework\Http\Status;
|
|
use App\Framework\Http\UploadedFile;
|
|
use App\Framework\LiveComponents\Batch\BatchProcessor;
|
|
use App\Framework\LiveComponents\Batch\BatchRequest;
|
|
use App\Framework\LiveComponents\ComponentRegistry;
|
|
use App\Framework\LiveComponents\Contracts\LifecycleAware;
|
|
use App\Framework\LiveComponents\Contracts\SupportsFileUpload;
|
|
use App\Framework\LiveComponents\LiveComponentHandler;
|
|
use App\Framework\LiveComponents\Rendering\FragmentRenderer;
|
|
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentAction;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentData;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
|
use App\Framework\Router\Result\JsonResult;
|
|
|
|
final readonly class LiveComponentController
|
|
{
|
|
public function __construct(
|
|
private ComponentRegistry $componentRegistry,
|
|
private LiveComponentHandler $handler,
|
|
private FragmentRenderer $fragmentRenderer,
|
|
private BatchProcessor $batchProcessor
|
|
) {
|
|
}
|
|
|
|
#[Route('/live-component/{id}', method: Method::POST)]
|
|
public function handleAction(string $id, HttpRequest $request): JsonResult
|
|
{
|
|
$action = ComponentAction::fromRequest($request, $id);
|
|
|
|
// Get state from request (as array, will be passed to component constructor)
|
|
$stateArray = $request->parsedBody?->get('state', []);
|
|
$initialData = is_array($stateArray) ? $stateArray : null;
|
|
|
|
// Get fragments parameter if present (for partial rendering)
|
|
$fragmentNames = $request->parsedBody?->get('fragments', []);
|
|
$fragmentNames = is_array($fragmentNames) ? $fragmentNames : [];
|
|
|
|
// Resolve component with current state (registry will convert array to State VO)
|
|
$component = $this->componentRegistry->resolve(
|
|
$action->componentId,
|
|
$initialData
|
|
);
|
|
|
|
// Handle action using LiveComponentHandler (composition)
|
|
// Returns ComponentUpdate with empty HTML
|
|
$update = $this->handler->handle(
|
|
$component,
|
|
$action->method,
|
|
$action->params
|
|
);
|
|
|
|
// Create new component instance with updated data for rendering
|
|
$updatedComponent = $this->componentRegistry->resolve(
|
|
$action->componentId,
|
|
$update->state->data // Array will be converted to State VO by registry
|
|
);
|
|
|
|
// Render fragments or full HTML based on request
|
|
if (! empty($fragmentNames)) {
|
|
// Partial rendering: extract requested fragments
|
|
$fragmentCollection = $this->fragmentRenderer->renderFragments(
|
|
$updatedComponent,
|
|
$fragmentNames
|
|
);
|
|
|
|
// If fragments found, return them
|
|
if (! $fragmentCollection->isEmpty()) {
|
|
return new JsonResult([
|
|
'fragments' => $fragmentCollection->toAssociativeArray(),
|
|
'state' => $update->state->toArray(),
|
|
'events' => $update->events,
|
|
]);
|
|
}
|
|
|
|
// Fallback to full render if fragments not found
|
|
}
|
|
|
|
// Full rendering (default or fallback)
|
|
$html = $this->componentRegistry->render($updatedComponent);
|
|
|
|
// Return ComponentUpdate with rendered HTML
|
|
return new JsonResult([
|
|
'html' => $html,
|
|
'state' => $update->state->toArray(), // ✅ Prevent double-encoding
|
|
'events' => $update->events,
|
|
]);
|
|
}
|
|
|
|
#[Route('/live-component/{id}/upload', method: Method::POST)]
|
|
public function handleUpload(string $id, HttpRequest $request): JsonResult
|
|
{
|
|
// Convert string ID to ComponentId
|
|
$componentId = ComponentId::fromString($id);
|
|
|
|
// Get state from request (as array)
|
|
$stateArray = $request->parsedBody?->get('state', []);
|
|
$initialData = is_array($stateArray) ? $stateArray : null;
|
|
|
|
$params = $request->parsedBody?->get('params', []);
|
|
|
|
// Resolve component (registry will convert array to State VO)
|
|
$component = $this->componentRegistry->resolve(
|
|
$componentId,
|
|
$initialData
|
|
);
|
|
|
|
// Check if component supports file uploads
|
|
if (! $component instanceof SupportsFileUpload) {
|
|
return new JsonResult([
|
|
'success' => false,
|
|
'error' => 'Component does not support file uploads',
|
|
], Status::BAD_REQUEST);
|
|
}
|
|
|
|
// Get uploaded file from request
|
|
$uploadedFiles = $request->files?->all() ?? [];
|
|
|
|
if (empty($uploadedFiles)) {
|
|
return new JsonResult([
|
|
'success' => false,
|
|
'error' => 'No file uploaded',
|
|
], Status::BAD_REQUEST);
|
|
}
|
|
|
|
// Get first uploaded file (support for multiple files can be added later)
|
|
$file = reset($uploadedFiles);
|
|
|
|
if (! $file instanceof UploadedFile) {
|
|
return new JsonResult([
|
|
'success' => false,
|
|
'error' => 'Invalid file upload',
|
|
], Status::BAD_REQUEST);
|
|
}
|
|
|
|
// Validate file
|
|
$validationErrors = $component->validateUpload($file);
|
|
|
|
if (! empty($validationErrors)) {
|
|
return new JsonResult([
|
|
'success' => false,
|
|
'errors' => $validationErrors,
|
|
], Status::UNPROCESSABLE_ENTITY);
|
|
}
|
|
|
|
try {
|
|
// Handle upload through handler (like regular actions)
|
|
$update = $this->handler->handleUpload(
|
|
$component,
|
|
$file,
|
|
ActionParameters::fromArray(is_array($params) ? $params : [])
|
|
);
|
|
|
|
// Create new component instance with updated data for rendering
|
|
$updatedComponent = $this->componentRegistry->resolve(
|
|
$componentId,
|
|
$update->state->data // Array will be converted to State VO
|
|
);
|
|
|
|
// Render HTML using ComponentRegistry
|
|
$html = $this->componentRegistry->render($updatedComponent);
|
|
|
|
// Return success response
|
|
return new JsonResult([
|
|
'success' => true,
|
|
'html' => $html,
|
|
'state' => $update->state->toArray(), // ✅ Prevent double-encoding
|
|
'events' => $update->events,
|
|
'file' => [
|
|
'name' => $file->name,
|
|
'size' => $file->size,
|
|
'type' => $file->type,
|
|
],
|
|
]);
|
|
} catch (\Exception $e) {
|
|
return new JsonResult([
|
|
'success' => false,
|
|
'error' => $e->getMessage(),
|
|
], Status::INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle lazy loading of component
|
|
*
|
|
* Renders component HTML on-demand when it enters viewport.
|
|
* Used by LazyComponentLoader for performance optimization.
|
|
*
|
|
* @param string $id Component ID
|
|
* @param HttpRequest $request HTTP request
|
|
* @return JsonResult JSON response with component HTML and state
|
|
*/
|
|
#[Route('/live-component/{id}/lazy-load', method: Method::GET)]
|
|
public function handleLazyLoad(string $id, HttpRequest $request): JsonResult
|
|
{
|
|
try {
|
|
$componentId = ComponentId::fromString($id);
|
|
|
|
// Resolve component with initial state (null = component will use its defaults)
|
|
$component = $this->componentRegistry->resolve(
|
|
$componentId,
|
|
initialData: null
|
|
);
|
|
|
|
// Render component HTML with wrapper
|
|
$html = $this->componentRegistry->renderWithWrapper($component);
|
|
|
|
// Get component state
|
|
$componentState = $component->state;
|
|
|
|
// Get CSRF token for component (each component has its own token)
|
|
$csrfToken = $this->componentRegistry->generateCsrfToken($componentId);
|
|
|
|
return new JsonResult([
|
|
'success' => true,
|
|
'html' => $html,
|
|
'state' => $componentState->toArray(),
|
|
'csrf_token' => $csrfToken,
|
|
'component_id' => $componentId->toString(),
|
|
]);
|
|
|
|
} catch (\Exception $e) {
|
|
return new JsonResult([
|
|
'success' => false,
|
|
'error' => $e->getMessage(),
|
|
'error_code' => 'LAZY_LOAD_FAILED',
|
|
], Status::INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle component destruction lifecycle hook
|
|
*
|
|
* Called by client-side JavaScript when component element is removed from DOM.
|
|
* Only calls onDestroy() if component implements LifecycleAware interface.
|
|
*/
|
|
#[Route('/live-component/{id}/destroy', method: Method::POST)]
|
|
public function handleDestroy(string $id, HttpRequest $request): JsonResult
|
|
{
|
|
try {
|
|
$componentId = ComponentId::fromString($id);
|
|
|
|
// Get state from request
|
|
$stateArray = $request->parsedBody?->get('state', []);
|
|
$initialData = is_array($stateArray) ? $stateArray : null;
|
|
|
|
// Resolve component with current state
|
|
$component = $this->componentRegistry->resolve(
|
|
$componentId,
|
|
$initialData
|
|
);
|
|
|
|
// Only call onDestroy() if component implements LifecycleAware
|
|
if ($component instanceof LifecycleAware) {
|
|
try {
|
|
$component->onDestroy();
|
|
} catch (\Throwable $e) {
|
|
// Log but don't fail - component is being destroyed anyway
|
|
error_log("Lifecycle hook onDestroy() failed for " . get_class($component) . ": " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
return new JsonResult([
|
|
'success' => true,
|
|
'message' => 'Component destroyed',
|
|
]);
|
|
|
|
} catch (\Exception $e) {
|
|
// Even if destroy fails, return success - component is gone from client
|
|
return new JsonResult([
|
|
'success' => true,
|
|
'message' => 'Component destroyed (with errors)',
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle batch request - multiple component operations in one request
|
|
*
|
|
* Request Format:
|
|
* {
|
|
* "operations": [
|
|
* {
|
|
* "componentId": "counter:demo",
|
|
* "method": "increment",
|
|
* "params": {"amount": 5},
|
|
* "fragments": ["counter-display"],
|
|
* "operationId": "op-1"
|
|
* },
|
|
* {
|
|
* "componentId": "user-stats:123",
|
|
* "method": "refresh",
|
|
* "operationId": "op-2"
|
|
* }
|
|
* ]
|
|
* }
|
|
*
|
|
* Response Format:
|
|
* {
|
|
* "results": [
|
|
* {
|
|
* "success": true,
|
|
* "operationId": "op-1",
|
|
* "fragments": {"counter-display": "<span>5</span>"},
|
|
* "state": {"count": 5},
|
|
* "events": []
|
|
* },
|
|
* {
|
|
* "success": false,
|
|
* "operationId": "op-2",
|
|
* "error": "Component not found",
|
|
* "errorCode": "COMPONENT_NOT_FOUND"
|
|
* }
|
|
* ],
|
|
* "total_operations": 2,
|
|
* "success_count": 1,
|
|
* "failure_count": 1
|
|
* }
|
|
*
|
|
* Features:
|
|
* - Executes operations sequentially with error isolation
|
|
* - Per-operation error handling (one failure doesn't stop batch)
|
|
* - Fragment support for partial updates
|
|
* - 60-80% fewer HTTP requests for multi-component updates
|
|
*/
|
|
#[Route('/live-component/batch', method: Method::POST)]
|
|
public function handleBatch(HttpRequest $request): JsonResult
|
|
{
|
|
try {
|
|
// Parse batch request
|
|
$requestData = $request->parsedBody?->toArray() ?? [];
|
|
$batchRequest = BatchRequest::fromArray($requestData);
|
|
|
|
// Process batch
|
|
$batchResponse = $this->batchProcessor->process($batchRequest);
|
|
|
|
// Return batch response
|
|
return new JsonResult($batchResponse->toArray());
|
|
|
|
} catch (\InvalidArgumentException $e) {
|
|
// Invalid batch request format
|
|
return new JsonResult([
|
|
'error' => $e->getMessage(),
|
|
'error_code' => 'INVALID_BATCH_REQUEST',
|
|
], Status::BAD_REQUEST);
|
|
|
|
} catch (\Throwable $e) {
|
|
// Unexpected error
|
|
return new JsonResult([
|
|
'error' => 'Batch processing failed: ' . $e->getMessage(),
|
|
'error_code' => 'BATCH_PROCESSING_FAILED',
|
|
], Status::INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
}
|