Files
michaelschiemer/docs/claude/sse-integration-guide.md
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- 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.
2025-10-25 19:18:37 +02:00

24 KiB

SSE Integration Guide

Praktischer Guide für die Integration von Server-Sent Events (SSE) in LiveComponents.

Quick Start

Eine LiveComponent SSE-fähig zu machen erfordert 2 einfache Schritte:

1. Action-Attribute hinzufügen

use App\Framework\LiveComponents\Attributes\Action;

#[Action]
public function markAsRead(string $notificationId): ComponentData
{
    // ... your logic ...
}

2. getSseChannel() Methode implementieren

public function getSseChannel(): string
{
    return "user:{$this->userId}";
}

Das war's! 🎉 Die Component verbindet sich automatisch zum SSE-Stream und empfängt Echtzeit-Updates.

Step-by-Step Tutorial

Schritt 1: Bestehende Component vorbereiten

Nehmen wir eine einfache NotificationComponent:

#[LiveComponent('notifications')]
final readonly class NotificationComponent implements LiveComponentContract
{
    private ComponentId $id;
    private NotificationState $state;

    public function __construct(
        ComponentId $id,
        ?ComponentData $initialData = null,
        array $notifications = []
    ) {
        $this->id = $id;

        if ($initialData !== null) {
            $this->state = NotificationState::fromComponentData($initialData);
            return;
        }

        $this->state = new NotificationState($notifications);
    }

    public function getId(): ComponentId
    {
        return $this->id;
    }

    public function getData(): ComponentData
    {
        return $this->state->toComponentData();
    }

    public function getRenderData(): ComponentRenderData
    {
        return new ComponentRenderData(
            templatePath: 'notifications',
            data: [
                'notifications' => $this->state->notifications,
                'unread_count' => $this->state->getUnreadCount()
            ]
        );
    }

    // Action methods...
    public function markAsRead(string $notificationId): ComponentData
    {
        $newState = $this->state->withNotificationRead($notificationId);
        return $newState->toComponentData();
    }

    public function deleteNotification(string $notificationId): ComponentData
    {
        $newState = $this->state->withNotificationDeleted($notificationId);
        return $newState->toComponentData();
    }
}

Schritt 2: Action-Attribute hinzufügen

Füge #[Action] zu allen Action-Methoden hinzu:

use App\Framework\LiveComponents\Attributes\Action;

#[Action]
public function markAsRead(string $notificationId): ComponentData
{
    $newState = $this->state->withNotificationRead($notificationId);
    return $newState->toComponentData();
}

#[Action]
public function deleteNotification(string $notificationId): ComponentData
{
    $newState = $this->state->withNotificationDeleted($notificationId);
    return $newState->toComponentData();
}

Warum? Das #[Action] Attribute markiert Methoden als sichere, von außen aufrufbare Actions.

Schritt 3: getSseChannel() implementieren

Wähle die passende Channel-Strategie für deinen Use Case:

Option A: User-Specific Channel

/**
 * Get SSE channel for real-time notification updates
 *
 * Uses user-specific channel for private notifications.
 * Falls back to 'global' for guest users.
 */
public function getSseChannel(): string
{
    // Extract user ID from component instance ID
    // ComponentId format: "notifications:user-123"
    $instanceId = $this->id->instanceId;

    if ($instanceId === 'global' || $instanceId === 'guest') {
        return 'global';
    }

    // User-specific channel for private notifications
    return "user:{$instanceId}";
}

Option B: Context-Specific Channel

/**
 * Get SSE channel for context-based notifications
 *
 * Examples:
 * - "notifications:post-123" → "notifications:post-123"
 * - "notifications:project-456" → "notifications:project-456"
 */
public function getSseChannel(): string
{
    $context = $this->id->instanceId;
    return "notifications:{$context}";
}

Option C: Global Channel

/**
 * Get SSE channel for system-wide notifications
 */
public function getSseChannel(): string
{
    return 'global';
}

Schritt 4: Testen

Die Component ist jetzt SSE-fähig! Teste es:

// Test-Script
use App\Framework\LiveComponents\ValueObjects\ComponentId;

$component = new NotificationComponent(
    id: ComponentId::fromString('notifications:user-123')
);

// Check if getSseChannel() exists and returns correct channel
$channel = $component->getSseChannel();
assert($channel === 'user:user-123');

// Check HTML rendering includes data-sse-channel attribute
$html = $componentRegistry->renderWithWrapper($component);
assert(str_contains($html, 'data-sse-channel="user:user-123"'));

Schritt 5: Verifizieren im Browser

  1. Öffne Browser DevTools (F12)
  2. Network Tab → Filter: "EventSource" oder "eventsource"
  3. Lade die Seite mit deiner Component
  4. Prüfe: Du solltest eine SSE-Verbindung zu /sse/events/{channel} sehen
  5. Trigger Action: Klicke auf einen Button der eine Action aufruft
  6. Prüfe Console: Du solltest SSE-Events sehen
[LiveComponent] Setting up SSE for notifications:user-123 on channel user:user-123
[SSE] Connected to user:user-123
[LiveComponent] SSE update received for notifications:user-123

Channel-Strategien

1. Global Channel

Verwendung: System-weite Updates für alle Benutzer

public function getSseChannel(): string
{
    return 'global';
}

Wann verwenden?

  • Public Activity Feeds
  • System Announcements
  • Global Statistics
  • User-spezifische Daten

Beispiel: Public Activity Feed

#[LiveComponent('activity-feed')]
final readonly class ActivityFeedComponent implements LiveComponentContract
{
    public function getSseChannel(): string
    {
        // Alle Benutzer sehen dasselbe Feed
        return 'global';
    }
}

2. User-Specific Channels

Verwendung: Benutzer-spezifische Updates

public function getSseChannel(): string
{
    $instanceId = $this->id->instanceId; // z.B. "user-123"

    if ($instanceId === 'guest' || $instanceId === 'global') {
        return 'global';
    }

    return "user:{$instanceId}";
}

Wann verwenden?

  • Private Benachrichtigungen
  • Personal Dashboards
  • User-spezifische Activity Feeds
  • Public Daten

Beispiel: Private Notification Center

#[LiveComponent('notification-center')]
final readonly class NotificationCenterComponent implements LiveComponentContract
{
    public function getSseChannel(): string
    {
        $instanceId = $this->id->instanceId;

        if ($instanceId === 'global' || $instanceId === 'guest') {
            return 'global'; // Fallback für Gäste
        }

        // User-spezifischer Channel: user:user-123
        return "user:{$instanceId}";
    }
}

3. Context-Based Channels

Verwendung: Kontext-spezifische Updates (Posts, Articles, Rooms)

public function getSseChannel(): string
{
    $context = $this->id->instanceId; // z.B. "post-123", "article-456"
    return "comments:{$context}";
}

Wann verwenden?

  • Post/Article Comments
  • Document Collaboration
  • Context-specific Updates
  • User-übergreifende Updates

Beispiel: Comment Thread

#[LiveComponent('comment-thread')]
final readonly class CommentThreadComponent implements LiveComponentContract
{
    public function getSseChannel(): string
    {
        // Extract context from instance ID
        // "comment-thread:post-123" → "comments:post-123"
        $context = $this->id->instanceId;
        return "comments:{$context}";
    }
}

4. Presence Channels

Verwendung: Real-time Präsenz-Tracking

public function getSseChannel(): string
{
    $room = $this->id->instanceId; // z.B. "lobby", "room-5"
    return "presence:{$room}";
}

Wann verwenden?

  • Live Presence Indicators
  • Active User Lists
  • Collaborative Editing Sessions
  • Asynchrone Updates

Beispiel: Live Presence

#[LiveComponent('live-presence')]
final readonly class LivePresenceComponent implements LiveComponentContract
{
    public function getSseChannel(): string
    {
        // Extract room from instance ID
        // "live-presence:lobby" → "presence:lobby"
        $room = $this->id->instanceId;
        return "presence:{$room}";
    }
}

Beispiel-Implementierungen

Beispiel 1: Notification Center (User-Specific)

use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Attributes\LiveComponent;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\ValueObjects\ComponentData;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;

#[LiveComponent('notification-center')]
final readonly class NotificationCenterComponent implements LiveComponentContract
{
    private ComponentId $id;
    private NotificationCenterState $state;

    public function __construct(
        ComponentId $id,
        ?ComponentData $initialData = null,
        array $notifications = []
    ) {
        $this->id = $id;

        if ($initialData !== null) {
            $this->state = NotificationCenterState::fromComponentData($initialData);
            return;
        }

        $this->state = new NotificationCenterState($notifications);
    }

    public function getId(): ComponentId
    {
        return $this->id;
    }

    public function getData(): ComponentData
    {
        return $this->state->toComponentData();
    }

    public function getRenderData(): ComponentRenderData
    {
        return new ComponentRenderData(
            templatePath: 'notification-center',
            data: [
                'notifications' => $this->state->notifications,
                'unread_count' => $this->state->getUnreadCount()
            ]
        );
    }

    #[Action]
    public function markAsRead(string $notificationId): ComponentData
    {
        $newState = $this->state->withNotificationRead($notificationId);
        return $newState->toComponentData();
    }

    #[Action]
    public function deleteNotification(string $notificationId): ComponentData
    {
        $newState = $this->state->withNotificationDeleted($notificationId);
        return $newState->toComponentData();
    }

    #[Action]
    public function markAllAsRead(): ComponentData
    {
        $newState = $this->state->withAllMarkedAsRead();
        return $newState->toComponentData();
    }

    /**
     * Get SSE channel for real-time notifications
     *
     * Uses user-specific channel for private notifications.
     * Falls back to 'global' for guest users.
     */
    public function getSseChannel(): string
    {
        $instanceId = $this->id->instanceId;

        if ($instanceId === 'global' || $instanceId === 'guest') {
            return 'global';
        }

        return "user:{$instanceId}";
    }
}

Beispiel 2: Comment Thread (Context-Specific)

#[LiveComponent('comment-thread')]
final readonly class CommentThreadComponent implements LiveComponentContract
{
    private ComponentId $id;
    private CommentThreadState $state;

    public function __construct(
        ComponentId $id,
        ?ComponentData $initialData = null,
        array $comments = []
    ) {
        $this->id = $id;

        if ($initialData !== null) {
            $this->state = CommentThreadState::fromComponentData($initialData);
            return;
        }

        $this->state = new CommentThreadState($comments);
    }

    public function getId(): ComponentId
    {
        return $this->id;
    }

    public function getData(): ComponentData
    {
        return $this->state->toComponentData();
    }

    public function getRenderData(): ComponentRenderData
    {
        return new ComponentRenderData(
            templatePath: 'comment-thread',
            data: [
                'comments' => $this->state->buildCommentTree(),
                'total_comments' => count($this->state->comments)
            ]
        );
    }

    #[Action]
    public function addComment(
        string $content,
        string $authorId,
        string $authorName,
        ?string $parentId = null
    ): ComponentData {
        $newState = $this->state->withCommentAdded($content, $authorId, $authorName, $parentId);
        return $newState->toComponentData();
    }

    #[Action]
    public function deleteComment(string $commentId): ComponentData
    {
        $newState = $this->state->withCommentDeleted($commentId);
        return $newState->toComponentData();
    }

    /**
     * Get SSE channel for real-time comment updates
     *
     * Uses context-based channel for post/article-specific comments.
     *
     * Examples:
     * - "comment-thread:post-123" → "comments:post-123"
     * - "comment-thread:article-456" → "comments:article-456"
     */
    public function getSseChannel(): string
    {
        $context = $this->id->instanceId;
        return "comments:{$context}";
    }
}

Beispiel 3: Activity Feed (Context or User)

#[LiveComponent('activity-feed')]
final readonly class ActivityFeedComponent implements LiveComponentContract
{
    private ComponentId $id;
    private ActivityFeedState $state;

    // ... constructor, getId(), getData(), getRenderData() ...

    #[Action]
    public function addActivity(
        string $activityType,
        string $userId,
        string $userName,
        string $content
    ): ComponentData {
        // ... implementation ...
    }

    #[Action]
    public function markAsRead(string $activityId): ComponentData
    {
        // ... implementation ...
    }

    #[Action]
    public function setFilter(string $filter): ComponentData
    {
        // ... implementation ...
    }

    /**
     * Get SSE channel for real-time activity updates
     *
     * Uses context-based channel for flexible activity tracking.
     *
     * Examples:
     * - "activity-feed:user-123" → "activity:user-123" (user-specific)
     * - "activity-feed:global" → "activity:global" (public feed)
     * - "activity-feed:team-5" → "activity:team-5" (team-specific)
     */
    public function getSseChannel(): string
    {
        $context = $this->id->instanceId;
        return "activity:{$context}";
    }
}

Beispiel 4: Live Presence (Presence Channel)

#[LiveComponent('live-presence')]
final readonly class LivePresenceComponent implements LiveComponentContract
{
    private ComponentId $id;
    private LivePresenceState $state;

    // ... constructor, getId(), getData(), getRenderData() ...

    #[Action]
    public function userJoined(string $userId, string $userName): ComponentData
    {
        $newState = $this->state->withUserJoined($userId, $userName);
        return $newState->toComponentData();
    }

    #[Action]
    public function userLeft(string $userId): ComponentData
    {
        $newState = $this->state->withUserLeft($userId);
        return $newState->toComponentData();
    }

    /**
     * Get SSE channel for real-time presence updates
     *
     * Uses presence-scoped channel for room-based tracking.
     *
     * Examples:
     * - "live-presence:lobby" → "presence:lobby"
     * - "live-presence:room-5" → "presence:room-5"
     * - "live-presence:global" → "presence:global"
     */
    public function getSseChannel(): string
    {
        $room = $this->id->instanceId;
        return "presence:{$room}";
    }
}

Testing

Unit Tests

Test die getSseChannel() Methode:

use App\Framework\LiveComponents\ValueObjects\ComponentId;

it('returns correct SSE channel for user', function () {
    $component = new NotificationCenterComponent(
        id: ComponentId::fromString('notification-center:user-123')
    );

    expect($component->getSseChannel())->toBe('user:user-123');
});

it('returns global channel for guest users', function () {
    $component = new NotificationCenterComponent(
        id: ComponentId::fromString('notification-center:guest')
    );

    expect($component->getSseChannel())->toBe('global');
});

Integration Tests

Test die vollständige SSE-Integration:

it('component renders with data-sse-channel attribute', function () {
    $component = new NotificationCenterComponent(
        id: ComponentId::fromString('notification-center:user-123')
    );

    $html = $this->componentRegistry->renderWithWrapper($component);

    expect($html)->toContain('data-sse-channel="user:user-123"');
});

it('component receives SSE updates', function () {
    // 1. Create component
    $component = new NotificationCenterComponent(
        id: ComponentId::fromString('notification-center:user-123')
    );

    // 2. Trigger action (dispatches ComponentUpdatedEvent)
    $newState = $component->markAsRead('notification-1');

    // 3. Verify SSE event was broadcasted
    $this->assertSseBroadcasted(
        channel: 'user:user-123',
        componentId: 'notification-center:user-123',
        eventType: 'component-update'
    );
});

Manual Browser Testing

  1. Öffne Browser DevTools
  2. Network Tab → Filter: "eventsource"
  3. Lade Seite mit SSE-Component
  4. Prüfe Connection: /sse/events/{channel} sollte sichtbar sein
  5. Trigger Action: Klicke Button
  6. Prüfe Console: SSE-Events sollten geloggt werden

Troubleshooting

Problem: Component verbindet sich nicht zu SSE

Symptome:

  • Keine SSE-Connection in Network Tab
  • Keine Console-Logs über SSE

Diagnose:

// Check 1: Hat Component getSseChannel()?
if (method_exists($component, 'getSseChannel')) {
    echo "✅ getSseChannel() exists\n";
    echo "Channel: " . $component->getSseChannel() . "\n";
} else {
    echo "❌ getSseChannel() missing\n";
}

// Check 2: Wird data-sse-channel im HTML gerendert?
$html = $componentRegistry->renderWithWrapper($component);
if (str_contains($html, 'data-sse-channel')) {
    echo "✅ data-sse-channel attribute present\n";
} else {
    echo "❌ data-sse-channel attribute missing\n";
}

Lösung:

  1. Implementiere getSseChannel() Methode
  2. Stelle sicher dass Component über ComponentRegistry::renderWithWrapper() gerendert wird

Problem: Updates kommen nicht an

Symptome:

  • SSE-Connection existiert
  • Aber Component updated nicht bei Actions

Diagnose:

// Browser Console
const sseClient = window.liveComponentManager.sseClients.get('user:user-123');
console.log('SSE Connected:', sseClient?.isConnected);
console.log('Component Channels:', Array.from(window.liveComponentManager.componentChannels));

Lösung:

  1. Prüfe ob Actions #[Action] Attribute haben
  2. Prüfe ob ComponentUpdatedEvent dispatched wird
  3. Prüfe Channel-Matching: Component channel === SSE channel

Problem: Reconnection-Loops

Symptome:

  • SSE disconnected/reconnected ständig
  • Console voll mit Reconnect-Meldungen

Diagnose:

# Server-Logs prüfen
docker exec php tail -f storage/logs/app.log | grep SSE

Lösung:

  • Server-Side Exceptions fixen (meist in /sse/events/{channel} Route)
  • Heartbeat-Timeout erhöhen falls Server langsam antwortet

Problem: Multiple Connections für Same Channel

Symptome:

  • Network Tab zeigt mehrere /sse/events/{channel} Connections
  • Memory Leaks

Diagnose:

// Browser Console
console.log('SSE Clients:', window.liveComponentManager.sseClients.size);
// Sollte 1 sein für 1 Channel

Lösung:

  • LiveComponentManager macht automatisch Connection Pooling
  • Prüfe ob Components richtig cleanup'd werden (keine Memory Leaks)

Best Practices

DO: Spezifische Channels

// ✅ Good: Spezifisch
public function getSseChannel(): string
{
    return "comments:post-{$this->postId}";
}

// ❌ Bad: Zu generisch
public function getSseChannel(): string
{
    return "all-updates";  // Zu breit, zu viele Events
}

DO: Dokumentiere Channel-Strategie

/**
 * Get SSE channel for real-time updates
 *
 * Uses user-specific channel for private notifications.
 * Falls back to 'global' for guest users.
 *
 * Channel format: "user:{userId}" or "global"
 *
 * Examples:
 * - "notification-center:user-123" → "user:user-123"
 * - "notification-center:guest" → "global"
 */
public function getSseChannel(): string
{
    // ... implementation
}

DO: Verwende $this->id->instanceId

// ✅ Good: Korrekte Property
$instanceId = $this->id->instanceId;

// ❌ Bad: Falsches Property (existiert nicht)
$instanceId = $this->id->identifier;

DO: Fallback für Global/Guest

public function getSseChannel(): string
{
    $instanceId = $this->id->instanceId;

    // Fallback für Gäste
    if ($instanceId === 'global' || $instanceId === 'guest') {
        return 'global';
    }

    return "user:{$instanceId}";
}

DO: Test SSE Integration

it('has getSseChannel method', function () {
    $component = new MyComponent(ComponentId::fromString('my:test'));
    expect(method_exists($component, 'getSseChannel'))->toBeTrue();
});

it('returns correct channel', function () {
    $component = new MyComponent(ComponentId::fromString('my:user-123'));
    expect($component->getSseChannel())->toBe('user:user-123');
});

DON'T: Hardcode User IDs

// ❌ Bad: Hardcoded User ID
public function getSseChannel(): string
{
    return "user:123";  // Falsch!
}

// ✅ Good: Dynamisch aus Component ID extrahieren
public function getSseChannel(): string
{
    return "user:{$this->id->instanceId}";
}

DON'T: Vergiss #[Action] Attribute

// ❌ Bad: Kein Action-Attribute
public function markAsRead(string $id): ComponentData
{
    // Wird nicht als Action erkannt
}

// ✅ Good: Mit Action-Attribute
#[Action]
public function markAsRead(string $id): ComponentData
{
    // Wird als Action erkannt und dispatched ComponentUpdatedEvent
}

Advanced Topics

Custom SSE Events Broadcasting

Manchmal möchtest du SSE-Updates von außerhalb einer Component-Action triggern:

use App\Framework\Sse\ValueObjects\SseChannel;
use App\Framework\Sse\ValueObjects\SseEvent;
use App\Framework\Sse\SseConnectionPool;

final readonly class NotificationService
{
    public function __construct(
        private SseConnectionPool $connectionPool
    ) {}

    public function sendNotification(string $userId, string $message): void
    {
        // 1. Save notification to database
        $this->notificationRepository->create($userId, $message);

        // 2. Broadcast SSE update
        $channel = new SseChannel("user:{$userId}");
        $event = SseEvent::componentUpdate(
            componentId: "notification-center:user-{$userId}",
            state: ['notifications' => $this->getNotifications($userId)],
            html: $this->renderNotifications($userId)
        );

        $this->connectionPool->broadcast($channel, $event);
    }
}

Partial Fragment Updates

Für Performance kannst du nur Teile der Component updaten:

$event = SseEvent::componentFragments(
    componentId: "notification-center:user-123",
    fragments: [
        '#unread-count' => '<span class="badge">5</span>',
        '#notification-list' => '<div>...</div>'
    ]
);

$this->connectionPool->broadcast($channel, $event);

Server-Side Channel Filtering

Validiere Channel-Zugriff auf Server-Side:

#[Route('/sse/events/{channel}', method: Method::GET)]
public function events(string $channel, HttpRequest $request): StreamedResponse
{
    // Validiere User-Specific Channels
    if (str_starts_with($channel, 'user:')) {
        $requestedUserId = substr($channel, 5);
        $actualUserId = $request->user->id;

        if ($requestedUserId !== $actualUserId) {
            throw new UnauthorizedException('Cannot access other user channels');
        }
    }

    // ... continue with SSE connection
}

Zusammenfassung

SSE-Integration in LiveComponents ist einfach und automatisch:

  1. Add #[Action] Attribute zu allen Action-Methoden
  2. Implement getSseChannel() mit passender Channel-Strategie
  3. Test die Integration mit Unit- und Browser-Tests
  4. Component verbindet sich automatisch und empfängt Updates

Das war's! Keine JavaScript-Code erforderlich, keine manuelle Event-Handling. Das Framework kümmert sich um:

  • Auto-Connection beim Component-Mount
  • Auto-Reconnection bei Verbindungsproblemen
  • Connection Pooling für Performance
  • DOM-Updates via LiveComponentManager

Next Steps: