- 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.
1069 lines
29 KiB
Markdown
1069 lines
29 KiB
Markdown
# SSE System Documentation
|
|
|
|
**Server-Sent Events (SSE) Integration** für das Custom PHP Framework - Technische Dokumentation.
|
|
|
|
## Übersicht
|
|
|
|
Das SSE-System ermöglicht **Echtzeit-Updates von Server zu Client** über HTTP-basierte unidirektionale Streams. LiveComponents können automatisch Updates empfangen und sich selbst neu rendern, ohne dass JavaScript-Code geschrieben werden muss.
|
|
|
|
### Kernfeatures
|
|
|
|
- ✅ **Auto-Connection**: Components mit `getSseChannel()` verbinden sich automatisch
|
|
- ✅ **Channel-Based Routing**: Typed channels für verschiedene Update-Typen
|
|
- ✅ **Event Broadcasting**: EventDispatcher Integration für nahtlose Updates
|
|
- ✅ **Automatic Reconnection**: Exponential Backoff mit Jitter (1s → 30s max)
|
|
- ✅ **Heartbeat Monitoring**: 45s Client-Timeout-Erkennung
|
|
- ✅ **DOM Patching**: Fragment-basierte partielle Rendering
|
|
- ✅ **Connection Pooling**: Effizientes Teilen von Connections zwischen Components
|
|
|
|
## Architektur
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Browser (Frontend) │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ LiveComponentManager │
|
|
│ ├─ setupSseConnection() - Auto-detect data-sse-channel │
|
|
│ ├─ handleSseComponentUpdate() - Full component refresh │
|
|
│ └─ handleSseComponentFragments() - Partial DOM updates │
|
|
│ │
|
|
│ SseClient (EventSource Wrapper) │
|
|
│ ├─ connect() - EventSource mit exponential backoff │
|
|
│ ├─ disconnect() - Graceful shutdown │
|
|
│ └─ Reference counting für shared connections │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
↓ SSE Stream (text/event-stream)
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ PHP Backend │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ ComponentRegistry │
|
|
│ └─ renderWithWrapper() - Auto-inject data-sse-channel │
|
|
│ │
|
|
│ SseEventBroadcaster (EventListener) │
|
|
│ ├─ onComponentUpdated() - Broadcast component updates │
|
|
│ └─ Filters by channel subscription │
|
|
│ │
|
|
│ SseConnectionPool │
|
|
│ ├─ Connection Management │
|
|
│ ├─ Heartbeat Scheduling (30s interval) │
|
|
│ └─ Broadcast to filtered connections │
|
|
│ │
|
|
│ /sse/events/{channel} Route │
|
|
│ ├─ text/event-stream Response │
|
|
│ ├─ Generator-based streaming │
|
|
│ └─ Connection lifecycle management │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
## Backend-Komponenten
|
|
|
|
### 1. SseChannel (Value Object)
|
|
|
|
**Location**: `src/Framework/Sse/ValueObjects/SseChannel.php`
|
|
|
|
Typed Channel-Identifier mit Validierung:
|
|
|
|
```php
|
|
final readonly class SseChannel
|
|
{
|
|
public function __construct(
|
|
public string $value
|
|
) {
|
|
if (empty($value)) {
|
|
throw new InvalidArgumentException('Channel cannot be empty');
|
|
}
|
|
}
|
|
|
|
public static function global(): self
|
|
{
|
|
return new self('global');
|
|
}
|
|
|
|
public static function user(string $userId): self
|
|
{
|
|
return new self("user:$userId");
|
|
}
|
|
|
|
public function equals(self $other): bool
|
|
{
|
|
return $this->value === $other->value;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. SseConnection (Value Object)
|
|
|
|
**Location**: `src/Framework/Sse/ValueObjects/SseConnection.php`
|
|
|
|
Repräsentiert eine aktive SSE-Verbindung:
|
|
|
|
```php
|
|
final readonly class SseConnection
|
|
{
|
|
public function __construct(
|
|
public string $id, // Unique connection ID
|
|
public SseChannel $channel, // Subscribed channel
|
|
public \DateTimeImmutable $connectedAt,
|
|
public ?\DateTimeImmutable $lastHeartbeat = null,
|
|
public array $metadata = [] // User-Agent, IP, etc.
|
|
) {}
|
|
|
|
public function isExpired(int $timeoutSeconds = 60): bool
|
|
{
|
|
$lastActivity = $this->lastHeartbeat ?? $this->connectedAt;
|
|
$now = new \DateTimeImmutable();
|
|
return ($now->getTimestamp() - $lastActivity->getTimestamp()) > $timeoutSeconds;
|
|
}
|
|
|
|
public function withHeartbeat(\DateTimeImmutable $timestamp): self
|
|
{
|
|
return new self(
|
|
id: $this->id,
|
|
channel: $this->channel,
|
|
connectedAt: $this->connectedAt,
|
|
lastHeartbeat: $timestamp,
|
|
metadata: $this->metadata
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. SseConnectionPool
|
|
|
|
**Location**: `src/Framework/Sse/SseConnectionPool.php`
|
|
|
|
Verwaltet alle aktiven SSE-Verbindungen:
|
|
|
|
```php
|
|
final class SseConnectionPool
|
|
{
|
|
/** @var array<string, SseConnection> */
|
|
private array $connections = [];
|
|
|
|
/** @var array<string, Generator> */
|
|
private array $generators = [];
|
|
|
|
public function add(SseConnection $connection, Generator $generator): void
|
|
{
|
|
$this->connections[$connection->id] = $connection;
|
|
$this->generators[$connection->id] = $generator;
|
|
}
|
|
|
|
public function remove(string $connectionId): void
|
|
{
|
|
unset($this->connections[$connectionId]);
|
|
unset($this->generators[$connectionId]);
|
|
}
|
|
|
|
/**
|
|
* Send event to all connections subscribed to channel
|
|
*/
|
|
public function broadcast(SseChannel $channel, SseEvent $event): void
|
|
{
|
|
foreach ($this->connections as $connectionId => $connection) {
|
|
if ($connection->channel->equals($channel)) {
|
|
$generator = $this->generators[$connectionId];
|
|
$generator->send($event);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send heartbeat to all connections
|
|
*/
|
|
public function sendHeartbeats(): void
|
|
{
|
|
$heartbeat = SseEvent::heartbeat();
|
|
foreach ($this->generators as $generator) {
|
|
$generator->send($heartbeat);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove expired connections
|
|
*/
|
|
public function cleanupExpired(int $timeoutSeconds = 60): int
|
|
{
|
|
$removed = 0;
|
|
foreach ($this->connections as $connectionId => $connection) {
|
|
if ($connection->isExpired($timeoutSeconds)) {
|
|
$this->remove($connectionId);
|
|
$removed++;
|
|
}
|
|
}
|
|
return $removed;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4. SseEvent (Value Object)
|
|
|
|
**Location**: `src/Framework/Sse/ValueObjects/SseEvent.php`
|
|
|
|
SSE-Event mit Typ und Daten:
|
|
|
|
```php
|
|
final readonly class SseEvent
|
|
{
|
|
public function __construct(
|
|
public SseEventType $type,
|
|
public array $data = [],
|
|
public ?string $id = null,
|
|
public ?int $retry = null
|
|
) {}
|
|
|
|
public static function heartbeat(): self
|
|
{
|
|
return new self(
|
|
type: SseEventType::HEARTBEAT,
|
|
data: ['timestamp' => time()]
|
|
);
|
|
}
|
|
|
|
public static function componentUpdate(
|
|
string $componentId,
|
|
array $state,
|
|
string $html
|
|
): self {
|
|
return new self(
|
|
type: SseEventType::COMPONENT_UPDATE,
|
|
data: [
|
|
'componentId' => $componentId,
|
|
'state' => $state,
|
|
'html' => $html
|
|
]
|
|
);
|
|
}
|
|
|
|
public static function componentFragments(
|
|
string $componentId,
|
|
array $fragments
|
|
): self {
|
|
return new self(
|
|
type: SseEventType::COMPONENT_FRAGMENTS,
|
|
data: [
|
|
'componentId' => $componentId,
|
|
'fragments' => $fragments
|
|
]
|
|
);
|
|
}
|
|
|
|
public function toSseFormat(): string
|
|
{
|
|
$output = '';
|
|
|
|
// Event type
|
|
if ($this->type !== SseEventType::MESSAGE) {
|
|
$output .= "event: {$this->type->value}\n";
|
|
}
|
|
|
|
// Event ID (for reconnection)
|
|
if ($this->id !== null) {
|
|
$output .= "id: {$this->id}\n";
|
|
}
|
|
|
|
// Retry interval (milliseconds)
|
|
if ($this->retry !== null) {
|
|
$output .= "retry: {$this->retry}\n";
|
|
}
|
|
|
|
// Data (JSON-encoded)
|
|
$output .= "data: " . json_encode($this->data) . "\n\n";
|
|
|
|
return $output;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5. SseEventBroadcaster (Event Listener)
|
|
|
|
**Location**: `src/Framework/Sse/SseEventBroadcaster.php`
|
|
|
|
Hört auf `ComponentUpdatedEvent` und broadcastet SSE-Events:
|
|
|
|
```php
|
|
#[EventHandler]
|
|
final readonly class SseEventBroadcaster
|
|
{
|
|
public function __construct(
|
|
private SseConnectionPool $connectionPool,
|
|
private ComponentRegistry $componentRegistry
|
|
) {}
|
|
|
|
public function onComponentUpdated(ComponentUpdatedEvent $event): void
|
|
{
|
|
// Get component instance to extract SSE channel
|
|
$component = $this->componentRegistry->get($event->componentId);
|
|
|
|
// Check if component supports SSE
|
|
if (!method_exists($component, 'getSseChannel')) {
|
|
return; // Not an SSE-capable component
|
|
}
|
|
|
|
$channel = new SseChannel($component->getSseChannel());
|
|
|
|
// Render updated component
|
|
$html = $this->componentRegistry->render($component);
|
|
|
|
// Create SSE event
|
|
$sseEvent = SseEvent::componentUpdate(
|
|
componentId: $event->componentId->toString(),
|
|
state: $event->newState->toArray(),
|
|
html: $html
|
|
);
|
|
|
|
// Broadcast to all subscribed connections
|
|
$this->connectionPool->broadcast($channel, $sseEvent);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 6. SSE Route Controller
|
|
|
|
**Location**: `src/Application/Api/SseController.php`
|
|
|
|
```php
|
|
final readonly class SseController
|
|
{
|
|
#[Route(path: '/sse/events/{channel}', method: Method::GET)]
|
|
public function events(string $channel, HttpRequest $request): StreamedResponse
|
|
{
|
|
$sseChannel = new SseChannel($channel);
|
|
$connectionId = uniqid('sse_', true);
|
|
|
|
$connection = new SseConnection(
|
|
id: $connectionId,
|
|
channel: $sseChannel,
|
|
connectedAt: new \DateTimeImmutable(),
|
|
metadata: [
|
|
'user_agent' => $request->headers->getFirst(HeaderKey::USER_AGENT),
|
|
'ip' => $request->server->getClientIp()
|
|
]
|
|
);
|
|
|
|
// Generator für SSE-Stream
|
|
$generator = $this->createEventGenerator();
|
|
|
|
// Connection registrieren
|
|
$this->connectionPool->add($connection, $generator);
|
|
|
|
// Cleanup bei Verbindungsabbruch
|
|
register_shutdown_function(function() use ($connectionId) {
|
|
$this->connectionPool->remove($connectionId);
|
|
});
|
|
|
|
return new StreamedResponse(
|
|
generator: $generator,
|
|
headers: [
|
|
'Content-Type' => 'text/event-stream',
|
|
'Cache-Control' => 'no-cache',
|
|
'X-Accel-Buffering' => 'no'
|
|
]
|
|
);
|
|
}
|
|
|
|
private function createEventGenerator(): Generator
|
|
{
|
|
// Initial connection event
|
|
yield SseEvent::heartbeat();
|
|
|
|
// Keep connection open and wait for events
|
|
while (true) {
|
|
// Generator wird von außen mit send() gefüttert
|
|
$event = yield;
|
|
|
|
if ($event === null) {
|
|
// Heartbeat alle 30s
|
|
sleep(30);
|
|
yield SseEvent::heartbeat();
|
|
} else {
|
|
yield $event;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Frontend-Komponenten
|
|
|
|
### 1. SseClient
|
|
|
|
**Location**: `resources/js/modules/sse/index.js`
|
|
|
|
EventSource-Wrapper mit Reconnection-Logik:
|
|
|
|
```javascript
|
|
export class SseClient {
|
|
constructor(channels = [], options = {}) {
|
|
this.channels = channels;
|
|
this.baseUrl = options.baseUrl || '/sse/events';
|
|
this.autoReconnect = options.autoReconnect ?? true;
|
|
this.heartbeatTimeout = options.heartbeatTimeout || 45000;
|
|
|
|
this.eventSource = null;
|
|
this.reconnectAttempts = 0;
|
|
this.maxReconnectDelay = 30000;
|
|
this.heartbeatTimer = null;
|
|
this.listeners = new Map();
|
|
this.isConnected = false;
|
|
}
|
|
|
|
connect() {
|
|
if (this.eventSource) {
|
|
return; // Already connected
|
|
}
|
|
|
|
const channel = this.channels[0] || 'global';
|
|
const url = `${this.baseUrl}/${channel}`;
|
|
|
|
this.eventSource = new EventSource(url);
|
|
|
|
this.eventSource.onopen = () => {
|
|
console.log(`[SSE] Connected to ${channel}`);
|
|
this.isConnected = true;
|
|
this.reconnectAttempts = 0;
|
|
this.resetHeartbeatTimer();
|
|
this.emit('connect', { channel });
|
|
};
|
|
|
|
this.eventSource.onerror = (error) => {
|
|
console.error('[SSE] Connection error:', error);
|
|
this.isConnected = false;
|
|
this.clearHeartbeatTimer();
|
|
|
|
if (this.eventSource.readyState === EventSource.CLOSED) {
|
|
this.eventSource = null;
|
|
if (this.autoReconnect) {
|
|
this.scheduleReconnect();
|
|
}
|
|
}
|
|
};
|
|
|
|
// Event-Listener registrieren
|
|
this.eventSource.addEventListener('heartbeat', () => {
|
|
this.resetHeartbeatTimer();
|
|
});
|
|
|
|
this.eventSource.addEventListener('component-update', (event) => {
|
|
const data = JSON.parse(event.data);
|
|
this.emit('component-update', data);
|
|
});
|
|
|
|
this.eventSource.addEventListener('component-fragments', (event) => {
|
|
const data = JSON.parse(event.data);
|
|
this.emit('component-fragments', data);
|
|
});
|
|
}
|
|
|
|
disconnect() {
|
|
this.autoReconnect = false;
|
|
this.clearHeartbeatTimer();
|
|
|
|
if (this.eventSource) {
|
|
this.eventSource.close();
|
|
this.eventSource = null;
|
|
}
|
|
|
|
this.isConnected = false;
|
|
this.reconnectAttempts = 0;
|
|
}
|
|
|
|
scheduleReconnect() {
|
|
const delay = Math.min(
|
|
1000 * Math.pow(2, this.reconnectAttempts) + Math.random() * 1000,
|
|
this.maxReconnectDelay
|
|
);
|
|
|
|
console.log(`[SSE] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`);
|
|
|
|
setTimeout(() => {
|
|
this.reconnectAttempts++;
|
|
this.connect();
|
|
}, delay);
|
|
}
|
|
|
|
resetHeartbeatTimer() {
|
|
this.clearHeartbeatTimer();
|
|
this.heartbeatTimer = setTimeout(() => {
|
|
console.warn('[SSE] Heartbeat timeout - connection lost');
|
|
this.disconnect();
|
|
if (this.autoReconnect) {
|
|
this.scheduleReconnect();
|
|
}
|
|
}, this.heartbeatTimeout);
|
|
}
|
|
|
|
clearHeartbeatTimer() {
|
|
if (this.heartbeatTimer) {
|
|
clearTimeout(this.heartbeatTimer);
|
|
this.heartbeatTimer = null;
|
|
}
|
|
}
|
|
|
|
on(eventType, handler) {
|
|
if (!this.listeners.has(eventType)) {
|
|
this.listeners.set(eventType, []);
|
|
}
|
|
this.listeners.get(eventType).push(handler);
|
|
}
|
|
|
|
off(eventType, handler) {
|
|
if (this.listeners.has(eventType)) {
|
|
const handlers = this.listeners.get(eventType);
|
|
const index = handlers.indexOf(handler);
|
|
if (index !== -1) {
|
|
handlers.splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
emit(eventType, data) {
|
|
if (this.listeners.has(eventType)) {
|
|
this.listeners.get(eventType).forEach(handler => handler(data));
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. LiveComponentManager SSE Integration
|
|
|
|
**Location**: `resources/js/modules/livecomponent/index.js`
|
|
|
|
```javascript
|
|
class LiveComponentManager {
|
|
constructor() {
|
|
this.components = new Map();
|
|
this.sseClients = new Map(); // channel → SseClient
|
|
this.componentChannels = new Map(); // componentId → channel
|
|
}
|
|
|
|
register(element) {
|
|
const componentId = element.dataset.liveComponent;
|
|
// ... existing registration logic ...
|
|
|
|
// Setup SSE if component has data-sse-channel attribute
|
|
this.setupSseConnection(element, componentId);
|
|
}
|
|
|
|
setupSseConnection(element, componentId) {
|
|
const sseChannel = element.dataset.sseChannel;
|
|
if (!sseChannel) {
|
|
return; // Component doesn't support SSE
|
|
}
|
|
|
|
console.log(`[LiveComponent] Setting up SSE for ${componentId} on channel ${sseChannel}`);
|
|
|
|
// Track component → channel mapping
|
|
this.componentChannels.set(componentId, sseChannel);
|
|
|
|
// Reuse existing SSE client for this channel or create new one
|
|
let sseClient = this.sseClients.get(sseChannel);
|
|
if (!sseClient) {
|
|
sseClient = new SseClient([sseChannel], {
|
|
autoReconnect: true,
|
|
heartbeatTimeout: 45000
|
|
});
|
|
|
|
// Register event handlers
|
|
this.registerSseHandlers(sseClient, sseChannel);
|
|
|
|
// Connect to SSE stream
|
|
sseClient.connect();
|
|
|
|
// Store for reuse
|
|
this.sseClients.set(sseChannel, sseClient);
|
|
}
|
|
}
|
|
|
|
registerSseHandlers(sseClient, channel) {
|
|
// Handle component updates
|
|
sseClient.on('component-update', (data) => {
|
|
this.handleSseComponentUpdate(data, channel);
|
|
});
|
|
|
|
// Handle partial fragment updates
|
|
sseClient.on('component-fragments', (data) => {
|
|
this.handleSseComponentFragments(data, channel);
|
|
});
|
|
|
|
// Connection status
|
|
sseClient.on('connect', () => {
|
|
console.log(`[LiveComponent] SSE connected: ${channel}`);
|
|
});
|
|
}
|
|
|
|
handleSseComponentUpdate(data, channel) {
|
|
const { componentId, state, html, events } = data;
|
|
const config = this.components.get(componentId);
|
|
|
|
if (!config) {
|
|
console.warn(`[LiveComponent] Component ${componentId} not found for SSE update`);
|
|
return;
|
|
}
|
|
|
|
console.log(`[LiveComponent] SSE update received for ${componentId}`);
|
|
|
|
// Update component HTML
|
|
if (html) {
|
|
config.element.innerHTML = html;
|
|
this.setupActionHandlers(config.element);
|
|
this.setupFileUploadHandlers(config.element);
|
|
}
|
|
|
|
// Update component state
|
|
if (state) {
|
|
config.element.dataset.state = JSON.stringify(state);
|
|
}
|
|
|
|
// Dispatch server-side events
|
|
if (events) {
|
|
this.handleServerEvents(events);
|
|
}
|
|
}
|
|
|
|
handleSseComponentFragments(data, channel) {
|
|
const { componentId, fragments } = data;
|
|
const config = this.components.get(componentId);
|
|
|
|
if (!config) {
|
|
return;
|
|
}
|
|
|
|
console.log(`[LiveComponent] SSE fragment update for ${componentId}`, fragments);
|
|
|
|
// Use DomPatcher for partial updates
|
|
this.updateFragments(config.element, fragments);
|
|
}
|
|
|
|
cleanup(componentId) {
|
|
const channel = this.componentChannels.get(componentId);
|
|
if (!channel) {
|
|
return;
|
|
}
|
|
|
|
// Remove channel mapping
|
|
this.componentChannels.delete(componentId);
|
|
|
|
// Check if any other component uses this channel
|
|
const stillInUse = Array.from(this.componentChannels.values())
|
|
.some(ch => ch === channel);
|
|
|
|
// Only disconnect if no other components use this channel
|
|
if (!stillInUse) {
|
|
const sseClient = this.sseClients.get(channel);
|
|
if (sseClient) {
|
|
console.log(`[LiveComponent] Disconnecting SSE: ${channel}`);
|
|
sseClient.disconnect();
|
|
this.sseClients.delete(channel);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Channel-Strategien
|
|
|
|
### Global Channel
|
|
|
|
**Verwendung**: System-weite Updates für alle Benutzer
|
|
|
|
```php
|
|
public function getSseChannel(): string
|
|
{
|
|
return 'global';
|
|
}
|
|
```
|
|
|
|
**Beispiele**:
|
|
- System-Announcements
|
|
- Public Activity Feeds
|
|
- Global Notification Center
|
|
|
|
### User-Specific Channels
|
|
|
|
**Verwendung**: Benutzer-spezifische Updates
|
|
|
|
```php
|
|
public function getSseChannel(): string
|
|
{
|
|
$instanceId = $this->id->instanceId; // "user-123"
|
|
|
|
if ($instanceId === 'guest' || $instanceId === 'global') {
|
|
return 'global';
|
|
}
|
|
|
|
return "user:{$instanceId}";
|
|
}
|
|
```
|
|
|
|
**Beispiele**:
|
|
- Private Benachrichtigungen
|
|
- User-spezifische Activity Feeds
|
|
- Personal Dashboards
|
|
|
|
### Context-Based Channels
|
|
|
|
**Verwendung**: Kontext-spezifische Updates (Posts, Articles, Rooms)
|
|
|
|
```php
|
|
public function getSseChannel(): string
|
|
{
|
|
$context = $this->id->instanceId; // "post-123", "article-456"
|
|
return "comments:{$context}";
|
|
}
|
|
```
|
|
|
|
**Beispiele**:
|
|
- Post/Article Comments
|
|
- Chat Rooms
|
|
- Document Collaboration
|
|
|
|
### Presence Channels
|
|
|
|
**Verwendung**: Real-time Präsenz-Tracking
|
|
|
|
```php
|
|
public function getSseChannel(): string
|
|
{
|
|
$room = $this->id->instanceId; // "lobby", "room-5"
|
|
return "presence:{$room}";
|
|
}
|
|
```
|
|
|
|
**Beispiele**:
|
|
- Room Presence
|
|
- Active User Lists
|
|
- Collaborative Editing Sessions
|
|
|
|
## Event-System Integration
|
|
|
|
### ComponentUpdatedEvent
|
|
|
|
Wird automatisch dispatched wenn eine Component-Action ausgeführt wird:
|
|
|
|
```php
|
|
final readonly class LiveComponentActionHandler
|
|
{
|
|
public function handleAction(
|
|
string $componentId,
|
|
string $actionName,
|
|
array $parameters
|
|
): ComponentData {
|
|
// Action ausführen
|
|
$newState = $component->$actionName(...$parameters);
|
|
|
|
// Event dispatchen für SSE Broadcasting
|
|
$this->eventDispatcher->dispatch(
|
|
new ComponentUpdatedEvent(
|
|
componentId: ComponentId::fromString($componentId),
|
|
actionName: $actionName,
|
|
oldState: $component->getData(),
|
|
newState: $newState
|
|
)
|
|
);
|
|
|
|
return $newState;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Custom Events
|
|
|
|
Du kannst auch manuelle SSE-Updates dispatchen:
|
|
|
|
```php
|
|
// In deinem Service/Controller
|
|
$event = new ComponentUpdatedEvent(
|
|
componentId: ComponentId::fromString('notification-center:user-123'),
|
|
actionName: 'external_notification',
|
|
oldState: $oldState,
|
|
newState: $newState
|
|
);
|
|
|
|
$this->eventDispatcher->dispatch($event);
|
|
```
|
|
|
|
## Performance-Aspekte
|
|
|
|
### Connection Pooling
|
|
|
|
- **Shared Connections**: Mehrere Components auf demselben Channel teilen eine SSE-Verbindung
|
|
- **Reference Counting**: Connection wird erst geschlossen wenn keine Components mehr subscriben
|
|
- **Memory Efficiency**: Nur eine EventSource pro Channel
|
|
|
|
### Heartbeat Strategy
|
|
|
|
- **Server-Side**: 30s Heartbeat-Interval
|
|
- **Client-Side**: 45s Timeout-Erkennung
|
|
- **Graceful Degradation**: Automatic Reconnection mit Exponential Backoff
|
|
|
|
### Reconnection Strategy
|
|
|
|
```javascript
|
|
// Exponential Backoff mit Jitter
|
|
delay = min(
|
|
1000 * 2^attempts + random(0, 1000),
|
|
30000 // max 30s
|
|
)
|
|
```
|
|
|
|
**Reconnection Times**:
|
|
- Attempt 1: ~1s + jitter
|
|
- Attempt 2: ~2s + jitter
|
|
- Attempt 3: ~4s + jitter
|
|
- Attempt 4: ~8s + jitter
|
|
- Attempt 5: ~16s + jitter
|
|
- Attempt 6+: 30s (capped)
|
|
|
|
### Bandwidth Optimization
|
|
|
|
- **Partial Updates**: Fragment-basierte Updates statt Full-Render
|
|
- **JSON Compression**: Minimale Payload-Größe
|
|
- **Conditional Rendering**: Nur betroffene Components updaten
|
|
|
|
## Error Handling
|
|
|
|
### Server-Side Errors
|
|
|
|
```php
|
|
try {
|
|
$sseEvent = SseEvent::componentUpdate(...);
|
|
$this->connectionPool->broadcast($channel, $sseEvent);
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('SSE broadcast failed', [
|
|
'channel' => $channel->value,
|
|
'error' => $e->getMessage()
|
|
]);
|
|
// Connection wird im Pool behalten, Retry bei nächstem Event
|
|
}
|
|
```
|
|
|
|
### Client-Side Errors
|
|
|
|
```javascript
|
|
sseClient.on('error', (error) => {
|
|
console.error('[SSE] Error:', error);
|
|
|
|
// Automatic reconnection wenn enabled
|
|
if (this.autoReconnect) {
|
|
this.scheduleReconnect();
|
|
}
|
|
});
|
|
```
|
|
|
|
### Connection Timeout
|
|
|
|
- **Heartbeat Monitoring**: 45s Client-Timeout
|
|
- **Server Cleanup**: Expired Connections werden nach 60s entfernt
|
|
- **Automatic Reconnect**: Client reconnected automatisch
|
|
|
|
## Best Practices
|
|
|
|
### 1. Channel Design
|
|
|
|
✅ **DO**: Spezifische Channels für verschiedene Kontexte
|
|
```php
|
|
return "comments:post-{$postId}"; // Spezifisch
|
|
```
|
|
|
|
❌ **DON'T**: Über-generalisierte Channels
|
|
```php
|
|
return "all-updates"; // Zu breit
|
|
```
|
|
|
|
### 2. Event Payload
|
|
|
|
✅ **DO**: Minimale Payloads mit nur notwendigen Daten
|
|
```php
|
|
SseEvent::componentUpdate(
|
|
componentId: $id,
|
|
state: $state,
|
|
html: $html // Nur wenn Full-Render notwendig
|
|
);
|
|
```
|
|
|
|
❌ **DON'T**: Große Payloads mit unnötigen Daten
|
|
|
|
### 3. Connection Management
|
|
|
|
✅ **DO**: Reference Counting für Shared Connections
|
|
```javascript
|
|
// Automatisch in LiveComponentManager
|
|
```
|
|
|
|
❌ **DON'T**: Eine Connection pro Component
|
|
```javascript
|
|
// Vermeiden: new SseClient() für jede Component
|
|
```
|
|
|
|
### 4. Error Handling
|
|
|
|
✅ **DO**: Graceful Degradation
|
|
```javascript
|
|
sseClient.on('error', () => {
|
|
// Continue operation without real-time updates
|
|
this.fallbackToPolling();
|
|
});
|
|
```
|
|
|
|
❌ **DON'T**: Hard Failures bei SSE-Problemen
|
|
|
|
### 5. Testing
|
|
|
|
✅ **DO**: Mock SSE in Tests
|
|
```php
|
|
$mockBroadcaster = new class implements EventListener {
|
|
public function onComponentUpdated($event): void {
|
|
// Track calls in test
|
|
}
|
|
};
|
|
```
|
|
|
|
## Debugging
|
|
|
|
### Server-Side Logging
|
|
|
|
```php
|
|
// In SseEventBroadcaster
|
|
$this->logger->debug('SSE broadcast', [
|
|
'channel' => $channel->value,
|
|
'component' => $event->componentId->toString(),
|
|
'action' => $event->actionName,
|
|
'connections' => count($this->connectionPool->getByChannel($channel))
|
|
]);
|
|
```
|
|
|
|
### Client-Side Logging
|
|
|
|
```javascript
|
|
// Enable verbose logging
|
|
const sseClient = new SseClient(['user:123'], {
|
|
debug: true // Logs alle Events
|
|
});
|
|
```
|
|
|
|
### Browser DevTools
|
|
|
|
- **Network Tab**: SSE-Stream als `eventsource` Type sichtbar
|
|
- **Console**: SSE-Events werden geloggt
|
|
- **EventSource Status**: `readyState` Property prüfen
|
|
|
|
## Troubleshooting
|
|
|
|
### Component Updates kommen nicht an
|
|
|
|
**Diagnose**:
|
|
1. Prüfe ob Component `getSseChannel()` implementiert
|
|
2. Prüfe ob `data-sse-channel` Attribute im HTML vorhanden ist
|
|
3. Prüfe Browser Console für SSE Connection-Status
|
|
4. Prüfe Server-Logs für Broadcasting-Events
|
|
|
|
**Lösung**:
|
|
```php
|
|
// Component muss getSseChannel() haben
|
|
public function getSseChannel(): string {
|
|
return "user:{$this->userId}";
|
|
}
|
|
|
|
// ComponentRegistry fügt automatisch data-sse-channel hinzu
|
|
```
|
|
|
|
### Reconnection-Loops
|
|
|
|
**Ursache**: Server wirft Exceptions bei SSE-Route
|
|
|
|
**Lösung**: Prüfe Server-Logs und fixe Exception-Ursache
|
|
|
|
### Memory Leaks
|
|
|
|
**Ursache**: Connections werden nicht cleanup'd
|
|
|
|
**Lösung**:
|
|
```php
|
|
// Automatisches Cleanup alle 5 Minuten
|
|
$this->scheduler->schedule(
|
|
'sse-cleanup',
|
|
IntervalSchedule::every(Duration::fromMinutes(5)),
|
|
fn() => $this->connectionPool->cleanupExpired()
|
|
);
|
|
```
|
|
|
|
## Sicherheit
|
|
|
|
### Channel-Isolation
|
|
|
|
- Channels sind **nicht authentifiziert** - verwende Server-Side Filtering
|
|
- User-spezifische Channels müssen Server-Side validiert werden
|
|
|
|
```php
|
|
#[Route('/sse/events/{channel}')]
|
|
public function events(string $channel, HttpRequest $request): StreamedResponse
|
|
{
|
|
// Validiere dass User auf Channel zugreifen darf
|
|
if (str_starts_with($channel, 'user:')) {
|
|
$userId = substr($channel, 5);
|
|
if ($userId !== $request->user->id) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
}
|
|
|
|
// ... rest of implementation
|
|
}
|
|
```
|
|
|
|
### XSS Prevention
|
|
|
|
- Alle HTML aus SSE-Updates wird escaped
|
|
- Verwende DOMPurify für User-Generated Content
|
|
|
|
### Rate Limiting
|
|
|
|
- Implementiere Rate Limiting für SSE-Connections
|
|
- Limit: Max 5 Connections pro User
|
|
|
|
## Framework Integration
|
|
|
|
### Mit Event System
|
|
|
|
```php
|
|
// EventDispatcher → SseEventBroadcaster → SseConnectionPool
|
|
$this->eventDispatcher->dispatch(new ComponentUpdatedEvent(...));
|
|
```
|
|
|
|
### Mit Queue System
|
|
|
|
```php
|
|
// Für verzögerte Broadcasts
|
|
$this->queue->push(new BroadcastSseEventJob(
|
|
channel: $channel,
|
|
event: $event
|
|
));
|
|
```
|
|
|
|
### Mit Cache System
|
|
|
|
```php
|
|
// Cache letzte Events für neue Connections (Replay)
|
|
$this->cache->remember(
|
|
CacheKey::fromString("sse:last-events:{$channel}"),
|
|
fn() => $this->getLastEvents($channel),
|
|
Duration::fromMinutes(5)
|
|
);
|
|
```
|
|
|
|
## Zusammenfassung
|
|
|
|
Das SSE-System bietet:
|
|
|
|
✅ **Echtzeit-Updates** ohne Polling oder WebSockets
|
|
✅ **Zero-Config** für Components mit getSseChannel()
|
|
✅ **Auto-Reconnection** mit intelligenter Backoff-Strategie
|
|
✅ **Channel-Based Routing** für flexible Update-Strategien
|
|
✅ **Event-Integration** für nahtlose Backend-Updates
|
|
✅ **Connection Pooling** für Performance-Optimierung
|
|
✅ **Framework-Compliant** mit Value Objects und Readonly Classes
|
|
|
|
**Next Steps**:
|
|
- Siehe [SSE Integration Guide](./sse-integration-guide.md) für praktische Implementierung
|
|
- Siehe [LiveComponents System](./livecomponents-system.md) für Component-Entwicklung
|