Files
michaelschiemer/resources/js/modules/livecomponent/index.js
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

1588 lines
55 KiB
JavaScript

/**
* LiveComponent Client-Side Implementation
*
* Handles:
* - Component initialization and lifecycle
* - AJAX action dispatching
* - Server-sent events from component updates
* - Polling for Pollable components
* - Event-driven component-to-component communication
* - Fragment-based partial rendering with DOM patching
* - Real-time SSE updates for reactive components
*/
import { domPatcher } from './DomPatcher.js';
import { SseClient } from '../sse/index.js';
import { LazyComponentLoader } from './LazyComponentLoader.js';
import { NestedComponentHandler } from './NestedComponentHandler.js';
import { ComponentPlayground } from './ComponentPlayground.js';
import { ComponentFileUploader } from './ComponentFileUploader.js';
import { FileUploadWidget } from './FileUploadWidget.js';
import { optimisticStateManager } from './OptimisticStateManager.js';
import { accessibilityManager } from './AccessibilityManager.js';
class LiveComponentManager {
constructor() {
this.components = new Map();
this.eventHandlers = new Map();
this.debounceTimers = new Map();
this.domPatcher = domPatcher;
this.accessibilityManager = accessibilityManager;
// Note: No global CSRF token - each component has its own token
// SSE Integration
this.sseClients = new Map(); // channel → SseClient
this.componentChannels = new Map(); // componentId → channel
// Lazy Loading Integration
this.lazyLoader = null; // Initialized in init() or initLazyLoading()
// Nested Components Integration
this.nestedHandler = null; // Initialized in initNestedComponents()
// DevTools Integration
this.devTools = null; // Will be set by DevTools when available
}
/**
* Enable DevTools integration
* Called by LiveComponentDevTools during initialization
*/
enableDevTools(devToolsInstance) {
this.devTools = devToolsInstance;
console.log('[LiveComponent] DevTools integration enabled');
}
/**
* Log action to DevTools if enabled
*/
logActionToDevTools(componentId, actionName, params, startTime, endTime, success, error = null) {
if (!this.devTools) return;
this.devTools.logAction(componentId, actionName, params, startTime, endTime, success, error);
}
/**
* Log event to DevTools if enabled
*/
logEventToDevTools(eventName, data, source = 'server') {
if (!this.devTools) return;
this.devTools.logEvent(eventName, data, source);
}
/**
* Initialize Lazy Loading system
* Should be called once during application startup
*/
initLazyLoading() {
if (this.lazyLoader) {
console.warn('[LiveComponent] Lazy loading already initialized');
return;
}
this.lazyLoader = new LazyComponentLoader(this);
this.lazyLoader.init();
console.log('[LiveComponent] Lazy loading system initialized');
}
/**
* Initialize Nested Components system
* Should be called once during application startup
*/
initNestedComponents() {
if (this.nestedHandler) {
console.warn('[LiveComponent] Nested components already initialized');
return;
}
this.nestedHandler = new NestedComponentHandler(this);
this.nestedHandler.init();
console.log('[LiveComponent] Nested components system initialized');
}
/**
* Initialize LiveComponent
*/
init(element) {
const componentId = element.dataset.liveComponent;
if (!componentId) return;
const config = {
id: componentId,
element,
pollInterval: parseInt(element.dataset.pollInterval) || null,
pollTimer: null,
observer: null
};
this.components.set(componentId, config);
// Setup action handlers
this.setupActionHandlers(element);
// Setup file upload handlers
this.setupFileUploadHandlers(element);
// Setup polling if component is pollable
if (config.pollInterval) {
this.startPolling(componentId);
}
// Setup lifecycle observer for onDestroy hook
this.setupLifecycleObserver(element, componentId);
// Setup SSE connection if component has a channel
this.setupSseConnection(element, componentId);
// Setup accessibility features
this.setupAccessibility(componentId, element);
console.log(`[LiveComponent] Initialized: ${componentId}`);
}
/**
* Setup accessibility features for component
*/
setupAccessibility(componentId, element) {
// Create component-specific ARIA live region
const politeness = element.dataset.livePolite === 'assertive' ? 'assertive' : 'polite';
this.accessibilityManager.createComponentLiveRegion(componentId, element, politeness);
console.log(`[LiveComponent] Accessibility features enabled for ${componentId}`);
}
/**
* Setup MutationObserver to detect component removal from DOM
* Calls onDestroy() lifecycle hook on server when component is removed
*/
setupLifecycleObserver(element, componentId) {
const config = this.components.get(componentId);
if (!config) return;
// Create observer to watch for element removal
const observer = new MutationObserver((mutations) => {
// Check if element is no longer in DOM
if (!document.contains(element)) {
console.log(`[LiveComponent] Element removed from DOM: ${componentId}`);
this.callDestroyHook(componentId);
observer.disconnect();
}
});
// Observe parent node for childList changes
if (element.parentNode) {
observer.observe(element.parentNode, {
childList: true,
subtree: false
});
}
config.observer = observer;
}
/**
* Setup SSE connection for component
* Automatically connects to SSE if component has data-sse-channel attribute
*/
setupSseConnection(element, componentId) {
// Check if component has SSE channel
const sseChannel = element.dataset.sseChannel;
if (!sseChannel) {
return; // No SSE channel, skip
}
console.log(`[LiveComponent] Setting up SSE for ${componentId} on channel: ${sseChannel}`);
// Track component-channel mapping
this.componentChannels.set(componentId, sseChannel);
// Get or create SSE client for this channel
let sseClient = this.sseClients.get(sseChannel);
if (!sseClient) {
// Create new SSE client for this channel
sseClient = new SseClient([sseChannel], {
autoReconnect: true,
heartbeatTimeout: 45000
});
// Register global handlers for this channel
this.registerSseHandlers(sseClient, sseChannel);
// Connect
sseClient.connect();
this.sseClients.set(sseChannel, sseClient);
console.log(`[LiveComponent] Created SSE client for channel: ${sseChannel}`);
}
console.log(`[LiveComponent] SSE connection established for ${componentId}`);
}
/**
* Register SSE event handlers for a channel
*/
registerSseHandlers(sseClient, channel) {
// Handle component-update events
sseClient.on('component-update', (data) => {
this.handleSseComponentUpdate(data, channel);
});
// Handle component-fragments events
sseClient.on('component-fragments', (data) => {
this.handleSseComponentFragments(data, channel);
});
// Handle connection state changes
sseClient.on('connected', () => {
console.log(`[LiveComponent] SSE connected to channel: ${channel}`);
this.updateSseConnectionStatus(channel, 'connected');
});
sseClient.on('disconnected', () => {
console.log(`[LiveComponent] SSE disconnected from channel: ${channel}`);
this.updateSseConnectionStatus(channel, 'disconnected');
});
sseClient.on('reconnecting', (attempt) => {
console.log(`[LiveComponent] SSE reconnecting to channel ${channel} (attempt ${attempt})`);
this.updateSseConnectionStatus(channel, 'reconnecting');
});
sseClient.on('error', (error) => {
console.error(`[LiveComponent] SSE error on channel ${channel}:`, error);
this.updateSseConnectionStatus(channel, 'error');
});
}
/**
* Handle SSE component-update event
*/
handleSseComponentUpdate(data, channel) {
const { componentId, state, html, events } = data;
console.log(`[LiveComponent] SSE update for ${componentId}:`, { state, html, events });
const config = this.components.get(componentId);
if (!config) {
console.warn(`[LiveComponent] Received SSE update for unknown component: ${componentId}`);
return;
}
try {
// Capture focus before update
this.accessibilityManager.captureFocusState(componentId, config.element);
// Update full HTML if provided
if (html) {
config.element.innerHTML = html;
this.setupActionHandlers(config.element);
this.setupFileUploadHandlers(config.element);
}
// Update state
if (state) {
config.element.dataset.state = JSON.stringify(state);
}
// Restore focus after update
this.accessibilityManager.restoreFocus(componentId, config.element);
// Announce update to screen readers
this.accessibilityManager.announceUpdate(componentId, 'full');
// Dispatch events
if (events && Array.isArray(events)) {
this.handleServerEvents(events);
}
console.log(`[LiveComponent] SSE update applied to ${componentId}`);
} catch (error) {
console.error(`[LiveComponent] Failed to apply SSE update for ${componentId}:`, error);
}
}
/**
* Handle SSE component-fragments event
*/
handleSseComponentFragments(data, channel) {
const { componentId, fragments } = data;
console.log(`[LiveComponent] SSE fragments for ${componentId}:`, fragments);
const config = this.components.get(componentId);
if (!config) {
console.warn(`[LiveComponent] Received SSE fragments for unknown component: ${componentId}`);
return;
}
try {
// Update fragments using DOM patcher
this.updateFragments(config.element, fragments);
console.log(`[LiveComponent] SSE fragments applied to ${componentId}`);
} catch (error) {
console.error(`[LiveComponent] Failed to apply SSE fragments for ${componentId}:`, error);
}
}
/**
* Update SSE connection status indicators
*/
updateSseConnectionStatus(channel, status) {
// Find all components on this channel
this.componentChannels.forEach((componentChannel, componentId) => {
if (componentChannel === channel) {
const config = this.components.get(componentId);
if (!config) return;
// Update status attribute
config.element.dataset.sseStatus = status;
// Dispatch custom event for status change
config.element.dispatchEvent(new CustomEvent('livecomponent:sse:status', {
detail: { channel, status }
}));
}
});
}
/**
* Call onDestroy() lifecycle hook on server
* Uses navigator.sendBeacon for best-effort delivery even during page unload
*/
async callDestroyHook(componentId) {
const config = this.components.get(componentId);
if (!config) return;
try {
// Get current state
const stateJson = config.element.dataset.state || '{}';
const state = JSON.parse(stateJson);
// Get CSRF token
const csrfToken = config.element.dataset.csrfToken;
const payload = JSON.stringify({
state,
_csrf_token: csrfToken
});
// Try sendBeacon first for better reliability during page unload
const url = `/live-component/${componentId}/destroy`;
const blob = new Blob([payload], { type: 'application/json' });
if (navigator.sendBeacon && navigator.sendBeacon(url, blob)) {
console.log(`[LiveComponent] onDestroy() called via sendBeacon: ${componentId}`);
} else {
// Fallback to fetch for normal removal
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: payload
});
console.log(`[LiveComponent] onDestroy() called via fetch: ${componentId}`);
}
} catch (error) {
// Don't throw - component is being destroyed anyway
console.warn(`[LiveComponent] onDestroy() hook failed: ${componentId}`, error);
}
// Clean up local state
this.destroy(componentId);
}
/**
* Setup action click handlers
*/
setupActionHandlers(element) {
element.querySelectorAll('[data-live-action]').forEach(actionEl => {
// Handle form submissions
if (actionEl.tagName === 'FORM') {
actionEl.addEventListener('submit', async (e) => {
e.preventDefault();
const componentId = element.dataset.liveComponent;
const action = actionEl.dataset.liveAction;
// Collect all form values
const formValues = {};
const formData = new FormData(actionEl);
for (const [key, value] of formData.entries()) {
formValues[key] = value;
}
// Extract fragments for partial rendering
const fragments = this.extractFragments(actionEl);
await this.executeAction(componentId, action, formValues, fragments);
});
}
// Handle input elements with debouncing
else if (actionEl.tagName === 'INPUT' || actionEl.tagName === 'TEXTAREA' || actionEl.tagName === 'SELECT') {
// Default debounce for text inputs: 300ms, others: 0ms
const defaultDebounce = (actionEl.type === 'text' || actionEl.type === 'email' || actionEl.type === 'url' || actionEl.type === 'tel' || actionEl.tagName === 'TEXTAREA') ? 300 : 0;
const debounceMs = parseInt(actionEl.dataset.liveDebounce) ?? defaultDebounce;
actionEl.addEventListener('input', async (e) => {
const componentId = element.dataset.liveComponent;
const action = actionEl.dataset.liveAction;
const params = this.extractParams(actionEl);
// Add input value to params
if (actionEl.type === 'checkbox') {
params[actionEl.name || 'value'] = actionEl.checked ? 'yes' : 'no';
} else if (actionEl.type === 'radio') {
params[actionEl.name || 'value'] = actionEl.value;
} else {
params[actionEl.name || 'value'] = actionEl.value;
}
// Extract fragments for partial rendering
const fragments = this.extractFragments(actionEl);
if (debounceMs > 0) {
this.debouncedAction(componentId, action, params, debounceMs, fragments);
} else {
await this.executeAction(componentId, action, params, fragments);
}
});
// For radio buttons and checkboxes, also listen to 'change' event
if (actionEl.type === 'radio' || actionEl.type === 'checkbox') {
actionEl.addEventListener('change', async (e) => {
const componentId = element.dataset.liveComponent;
const action = actionEl.dataset.liveAction;
const params = this.extractParams(actionEl);
if (actionEl.type === 'checkbox') {
params[actionEl.name || 'value'] = actionEl.checked ? 'yes' : 'no';
} else if (actionEl.type === 'radio') {
params[actionEl.name || 'value'] = actionEl.value;
}
await this.executeAction(componentId, action, params);
});
}
} else {
// Handle button clicks
actionEl.addEventListener('click', async (e) => {
e.preventDefault();
const componentId = element.dataset.liveComponent;
const action = actionEl.dataset.liveAction;
let params = this.extractParams(actionEl);
// For submit/navigation actions, collect all form values
if (action === 'submit' || action === 'nextStep' || action === 'previousStep') {
const formValues = this.collectFormValues(element);
console.log('[LiveComponent] Collected form values:', formValues);
params = { ...formValues, ...params };
console.log('[LiveComponent] Final params:', params);
}
// Extract fragments for partial rendering
const fragments = this.extractFragments(actionEl);
await this.executeAction(componentId, action, params, fragments);
});
}
});
}
/**
* Debounced action execution
*/
debouncedAction(componentId, action, params, delay, fragments = null) {
const timerKey = `${componentId}_${action}`;
// Clear existing timer
if (this.debounceTimers.has(timerKey)) {
clearTimeout(this.debounceTimers.get(timerKey));
}
// Set new timer
const timer = setTimeout(async () => {
await this.executeAction(componentId, action, params, fragments);
this.debounceTimers.delete(timerKey);
}, delay);
this.debounceTimers.set(timerKey, timer);
}
/**
* Extract action parameters from element
*/
extractParams(element) {
const params = {};
// Extract data-param-* attributes
Object.keys(element.dataset).forEach(key => {
if (key.startsWith('param')) {
// Remove 'param' prefix and handle '-from' suffix
const paramKey = key.replace('param', '');
// Check if this is a -from parameter (e.g., data-param-field-from="value")
if (paramKey.endsWith('From')) {
// Extract parameter name (e.g., "fieldFrom" -> "field")
const paramName = paramKey.replace(/From$/, '').toLowerCase();
const fromAttribute = element.dataset[key]; // e.g., "value"
// Get value from the specified attribute
if (fromAttribute === 'value') {
params[paramName] = element.value;
} else {
params[paramName] = element[fromAttribute] || element.getAttribute(fromAttribute);
}
} else {
// Regular parameter (e.g., data-param-direction="desc")
const paramName = paramKey.toLowerCase();
params[paramName] = element.dataset[key];
}
}
});
return params;
}
/**
* Extract fragments from element
*
* Gets fragments to update from data-lc-fragments attribute.
* Format: data-lc-fragments="user-stats,recent-activity"
*
* @param {HTMLElement} element - Action element
* @returns {Array<string>|null} - Array of fragment names or null
*/
extractFragments(element) {
const fragmentsAttr = element.dataset.lcFragments;
if (!fragmentsAttr) {
return null;
}
// Split by comma and trim whitespace
return fragmentsAttr.split(',').map(name => name.trim()).filter(Boolean);
}
/**
* Collect all form input values from component
*/
collectFormValues(element) {
const values = {};
// Find all inputs, textareas, and selects within the component
const inputs = element.querySelectorAll('input, textarea, select');
inputs.forEach(input => {
const name = input.name;
if (!name) return; // Skip inputs without name
if (input.type === 'checkbox') {
values[name] = input.checked ? 'yes' : 'no';
} else if (input.type === 'radio') {
if (input.checked) {
values[name] = input.value;
}
} else {
values[name] = input.value;
}
});
return values;
}
/**
* Execute component action
*
* @param {string} componentId - Component ID
* @param {string} method - Action method name
* @param {Object} params - Action parameters
* @param {Array<string>} fragments - Optional fragment names for partial rendering
*/
async executeAction(componentId, method, params = {}, fragments = null) {
const config = this.components.get(componentId);
if (!config) {
console.error(`[LiveComponent] Unknown component: ${componentId}`);
return;
}
let operationId = null;
const startTime = performance.now();
try {
// Get current state from element
const stateJson = config.element.dataset.state || '{}';
const stateWrapper = JSON.parse(stateJson);
// Extract actual state data from wrapper format
// Wrapper format: {id, component, data, version}
// Server expects just the data object
const state = stateWrapper.data || stateWrapper;
// Apply optimistic update for immediate UI feedback
// This updates the UI before server confirmation
const optimisticState = optimisticStateManager.applyOptimisticUpdate(
componentId,
stateWrapper,
(currentState) => {
// Optimistic update function - predict state changes
// For now, we just mark that an operation is pending
// Component-specific logic could predict actual changes
return {
...currentState,
data: {
...currentState.data,
_optimistic: true
}
};
},
{ action: method, params }
);
operationId = optimisticStateManager.getPendingOperations(componentId)[0]?.id;
// Get component-specific CSRF token from element
const csrfToken = config.element.dataset.csrfToken;
if (!csrfToken) {
console.error(`[LiveComponent] Missing CSRF token for component: ${componentId}`);
throw new Error('CSRF token is required for component actions');
}
// Build request body
const requestBody = {
method,
params,
state,
_csrf_token: csrfToken
};
// Add fragments parameter if specified
if (fragments && Array.isArray(fragments) && fragments.length > 0) {
requestBody.fragments = fragments;
}
// Send AJAX request with CSRF token in body
// Component ID is in the URL, not in the body
const response = await fetch(`/live-component/${componentId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
console.error('[LiveComponent] Server error response:', errorText);
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const responseText = await response.text();
console.log('[LiveComponent] Raw response:', responseText.substring(0, 200));
let data;
try {
data = JSON.parse(responseText);
} catch (parseError) {
console.error('[LiveComponent] JSON parse error:', parseError);
console.error('[LiveComponent] Response text:', responseText);
throw new Error(`Failed to parse JSON response: ${parseError.message}`);
}
// Check for version conflict
const serverState = data.state || {};
const currentVersion = stateWrapper.version || 1;
const serverVersion = serverState.version || 1;
if (serverVersion !== currentVersion + 1) {
// Version conflict detected - server rejected our optimistic update
console.warn(`[LiveComponent] Version conflict: expected ${currentVersion + 1}, got ${serverVersion}`);
// Handle conflict via OptimisticStateManager
if (operationId) {
const conflictResolution = optimisticStateManager.handleConflict(
componentId,
operationId,
serverState,
{ expectedVersion: currentVersion + 1, actualVersion: serverVersion }
);
// Show conflict notification to user
if (conflictResolution.notification) {
this.showConflictNotification(componentId, conflictResolution.notification);
}
// Use server state (rollback)
data.state = conflictResolution.state;
}
} else {
// Success - confirm optimistic operation
if (operationId) {
optimisticStateManager.confirmOperation(componentId, operationId, serverState);
}
}
// Handle fragment-based response (partial rendering)
if (data.fragments) {
this.updateFragments(config.element, data.fragments);
}
// Handle full HTML response (fallback or no fragments requested)
else if (data.html) {
config.element.innerHTML = data.html;
this.setupActionHandlers(config.element);
}
// Update component state
if (data.state) {
config.element.dataset.state = JSON.stringify(data.state);
}
// Handle server events
if (data.events && data.events.length > 0) {
this.handleServerEvents(data.events);
}
console.log(`[LiveComponent] Action executed: ${componentId}.${method}`, data);
// Log successful action to DevTools
const endTime = performance.now();
this.logActionToDevTools(componentId, method, params, startTime, endTime, true);
} catch (error) {
console.error(`[LiveComponent] Action failed:`, error);
// Log failed action to DevTools
const endTime = performance.now();
this.logActionToDevTools(componentId, method, params, startTime, endTime, false, error.message);
// Rollback optimistic update on error
if (operationId) {
const snapshot = optimisticStateManager.getSnapshot(componentId);
if (snapshot) {
config.element.dataset.state = JSON.stringify(snapshot);
optimisticStateManager.clearPendingOperations(componentId);
optimisticStateManager.clearSnapshot(componentId);
}
}
this.handleError(componentId, error);
}
}
/**
* Update specific fragments using DOM patching
*
* Efficiently patches only the fragments that changed.
* Includes accessibility features: focus management and ARIA announcements.
*
* @param {HTMLElement} container - Component container element
* @param {Object.<string, string>} fragments - Map of fragment names to HTML
*/
updateFragments(container, fragments) {
const componentId = container.dataset.liveComponent;
// Capture focus state before patching (with data-lc-keep-focus support)
if (componentId) {
this.accessibilityManager.captureFocusState(componentId, container);
}
// Patch each fragment
const results = this.domPatcher.patchFragments(container, fragments);
// Restore focus after patching (respects data-lc-keep-focus)
if (componentId) {
this.accessibilityManager.restoreFocus(componentId, container);
}
// Re-setup action handlers for patched fragments
Object.keys(fragments).forEach(fragmentName => {
if (results[fragmentName]) {
const fragmentElement = container.querySelector(`[data-lc-fragment="${fragmentName}"]`);
if (fragmentElement) {
this.setupActionHandlers(fragmentElement);
}
}
});
// Announce update to screen readers
if (componentId) {
const fragmentNames = Object.keys(fragments).filter(name => results[name]);
if (fragmentNames.length > 0) {
this.accessibilityManager.announceUpdate(componentId, 'fragment', {
fragmentName: fragmentNames.join(', ')
});
}
}
// Log patching results
const successCount = Object.values(results).filter(Boolean).length;
console.log(`[LiveComponent] Patched ${successCount}/${Object.keys(fragments).length} fragments`, results);
}
/**
* Handle server-sent events
*/
handleServerEvents(events) {
events.forEach(event => {
const { name, payload, target } = event;
console.log(`[LiveComponent] Server event:`, { name, payload, target });
// Log event to DevTools
this.logEventToDevTools(name, { payload, target }, 'server');
if (target) {
// Targeted event - only for specific component
this.dispatchToComponent(target, name, payload);
} else {
// Broadcast event - to all listening components
this.broadcast(name, payload);
}
});
}
/**
* Dispatch event to specific component
*/
dispatchToComponent(targetComponentId, eventName, payload) {
const handlers = this.eventHandlers.get(eventName) || [];
handlers.forEach(handler => {
if (handler.componentId === targetComponentId) {
handler.callback(payload);
}
});
}
/**
* Broadcast event to all listeners
*/
broadcast(eventName, payload) {
const handlers = this.eventHandlers.get(eventName) || [];
handlers.forEach(handler => {
handler.callback(payload);
});
// Also dispatch as custom DOM event
document.dispatchEvent(new CustomEvent(`livecomponent:${eventName}`, {
detail: payload
}));
}
/**
* Register event listener
*/
on(componentId, eventName, callback) {
if (!this.eventHandlers.has(eventName)) {
this.eventHandlers.set(eventName, []);
}
this.eventHandlers.get(eventName).push({
componentId,
callback
});
console.log(`[LiveComponent] Event listener registered: ${componentId} -> ${eventName}`);
}
/**
* Start polling for component
*/
startPolling(componentId) {
const config = this.components.get(componentId);
if (!config || !config.pollInterval) return;
// Clear existing timer
if (config.pollTimer) {
clearInterval(config.pollTimer);
}
// Start new polling interval
config.pollTimer = setInterval(async () => {
await this.executeAction(componentId, 'poll', {});
}, config.pollInterval);
console.log(`[LiveComponent] Polling started: ${componentId} (${config.pollInterval}ms)`);
}
/**
* Stop polling for component
*/
stopPolling(componentId) {
const config = this.components.get(componentId);
if (!config || !config.pollTimer) return;
clearInterval(config.pollTimer);
config.pollTimer = null;
console.log(`[LiveComponent] Polling stopped: ${componentId}`);
}
// Note: getCSRFToken() removed - components now have their own tokens in data-csrf-token
/**
* Error handler
*/
handleError(componentId, error) {
const config = this.components.get(componentId);
if (!config) return;
// Show error in component
const errorHtml = `
<div class="livecomponent-error" style="padding: 1rem; background: #fee; color: #c00; border: 1px solid #faa; border-radius: 4px;">
<strong>LiveComponent Error:</strong> ${error.message}
</div>
`;
config.element.insertAdjacentHTML('beforeend', errorHtml);
// Auto-remove error after 5 seconds
setTimeout(() => {
config.element.querySelector('.livecomponent-error')?.remove();
}, 5000);
}
/**
* Show conflict notification
*
* Displays a user-friendly notification when version conflicts occur during optimistic updates.
*
* @param {string} componentId - Component identifier
* @param {Object} notification - Notification object from OptimisticStateManager
*/
showConflictNotification(componentId, notification) {
const config = this.components.get(componentId);
if (!config) return;
// Create conflict notification HTML
const notificationHtml = `
<div class="livecomponent-conflict" style="
padding: 1rem;
background: #fff3cd;
color: #856404;
border: 1px solid #ffc107;
border-radius: 4px;
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
">
<div>
<strong>${notification.title}</strong>
<p style="margin: 0.5rem 0 0 0;">${notification.message}</p>
</div>
${notification.canRetry ? `
<button
class="livecomponent-retry"
style="
padding: 0.5rem 1rem;
background: #ffc107;
color: #000;
border: none;
border-radius: 4px;
cursor: pointer;
margin-left: 1rem;
"
data-operation-id="${notification.operation.id}"
data-action="${notification.operation.metadata.action}"
>
Retry
</button>
` : ''}
</div>
`;
// Insert notification at the top of component
config.element.insertAdjacentHTML('afterbegin', notificationHtml);
// Setup retry button if applicable
if (notification.canRetry) {
const retryButton = config.element.querySelector('.livecomponent-retry');
if (retryButton) {
retryButton.addEventListener('click', async () => {
// Remove notification
config.element.querySelector('.livecomponent-conflict')?.remove();
// Retry the operation
await optimisticStateManager.retryOperation(
componentId,
notification.operation,
async (metadata) => {
return await this.executeAction(
componentId,
metadata.action,
metadata.params
);
}
);
});
}
}
// Auto-remove notification after 8 seconds
setTimeout(() => {
config.element.querySelector('.livecomponent-conflict')?.remove();
}, 8000);
console.log(`[LiveComponent] Conflict notification shown for ${componentId}`, notification);
}
/**
* Setup file upload handlers
*/
setupFileUploadHandlers(element) {
const componentId = element.dataset.liveComponent;
// Setup file input handlers
element.querySelectorAll('input[type="file"][data-live-upload]').forEach(input => {
input.addEventListener('change', async (e) => {
const files = e.target.files;
if (files.length > 0) {
await this.uploadFile(componentId, files[0]);
}
});
});
// Setup drag & drop zones
element.querySelectorAll('[data-live-dropzone]').forEach(dropzone => {
this.setupDropzone(componentId, dropzone);
});
}
/**
* Setup drag & drop zone
*/
setupDropzone(componentId, dropzone) {
// Prevent default drag behaviors
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropzone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
});
});
// Highlight drop zone on drag over
['dragenter', 'dragover'].forEach(eventName => {
dropzone.addEventListener(eventName, () => {
dropzone.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(eventName => {
dropzone.addEventListener(eventName, () => {
dropzone.classList.remove('drag-over');
});
});
// Handle file drop
dropzone.addEventListener('drop', async (e) => {
const files = e.dataTransfer.files;
if (files.length > 0) {
await this.uploadFile(componentId, files[0]);
}
});
}
/**
* Upload file to component
*/
async uploadFile(componentId, file, params = {}) {
const config = this.components.get(componentId);
if (!config) {
console.error(`[LiveComponent] Unknown component: ${componentId}`);
return;
}
try {
// Get current state
const stateJson = config.element.dataset.state || '{}';
const state = JSON.parse(stateJson);
// Create FormData for file upload
const formData = new FormData();
formData.append('file', file);
formData.append('state', JSON.stringify(state));
formData.append('params', JSON.stringify(params));
// Get component-specific CSRF token
const csrfToken = config.element.dataset.csrfToken;
if (!csrfToken) {
console.error(`[LiveComponent] Missing CSRF token for upload: ${componentId}`);
throw new Error('CSRF token is required for file uploads');
}
// Add CSRF token to form data
formData.append('_csrf_token', csrfToken);
// Show upload progress (optional)
this.showUploadProgress(componentId, 0);
// Send upload request
const response = await fetch(`/live-component/${componentId}/upload`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Upload failed');
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Upload failed');
}
// Update component HTML
if (data.html) {
config.element.innerHTML = data.html;
this.setupActionHandlers(config.element);
this.setupFileUploadHandlers(config.element);
}
// Update component state
if (data.state) {
config.element.dataset.state = JSON.stringify(data.state);
}
// Handle server events
if (data.events && data.events.length > 0) {
this.handleServerEvents(data.events);
}
// Hide upload progress
this.hideUploadProgress(componentId);
console.log(`[LiveComponent] File uploaded: ${componentId}`, data.file);
// Dispatch upload success event
document.dispatchEvent(new CustomEvent('livecomponent:upload:success', {
detail: { componentId, file: data.file }
}));
} catch (error) {
console.error(`[LiveComponent] Upload failed:`, error);
this.hideUploadProgress(componentId);
this.handleError(componentId, error);
// Dispatch upload error event
document.dispatchEvent(new CustomEvent('livecomponent:upload:error', {
detail: { componentId, error: error.message }
}));
}
}
/**
* Show upload progress indicator
*/
showUploadProgress(componentId, percent) {
const config = this.components.get(componentId);
if (!config) return;
let progressEl = config.element.querySelector('.livecomponent-upload-progress');
if (!progressEl) {
progressEl = document.createElement('div');
progressEl.className = 'livecomponent-upload-progress';
progressEl.style.cssText = `
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: #e0e0e0;
z-index: 1000;
`;
const bar = document.createElement('div');
bar.className = 'progress-bar';
bar.style.cssText = `
height: 100%;
background: #2196F3;
transition: width 0.3s ease;
width: ${percent}%;
`;
progressEl.appendChild(bar);
config.element.style.position = 'relative';
config.element.appendChild(progressEl);
} else {
const bar = progressEl.querySelector('.progress-bar');
bar.style.width = `${percent}%`;
}
}
/**
* Hide upload progress indicator
*/
hideUploadProgress(componentId) {
const config = this.components.get(componentId);
if (!config) return;
const progressEl = config.element.querySelector('.livecomponent-upload-progress');
if (progressEl) {
progressEl.remove();
}
}
/**
* Destroy component instance
* Clean up timers, observers, event handlers, SSE connections, and accessibility features
*/
destroy(componentId) {
const config = this.components.get(componentId);
// Stop polling
this.stopPolling(componentId);
// Disconnect MutationObserver
if (config && config.observer) {
config.observer.disconnect();
}
// Cleanup SSE connection
this.cleanupSseConnection(componentId);
// Cleanup accessibility features
this.accessibilityManager.cleanup(componentId);
// Remove from registry
this.components.delete(componentId);
// Remove event handlers for this component
this.eventHandlers.forEach((handlers, eventName) => {
const filtered = handlers.filter(h => h.componentId !== componentId);
this.eventHandlers.set(eventName, filtered);
});
console.log(`[LiveComponent] Destroyed: ${componentId}`);
}
/**
* Cleanup SSE connection when component is destroyed
*/
cleanupSseConnection(componentId) {
const channel = this.componentChannels.get(componentId);
if (!channel) {
return; // No SSE connection for this component
}
// Remove component-channel mapping
this.componentChannels.delete(componentId);
// Check if any other components are using this channel
let channelStillInUse = false;
for (const [, componentChannel] of this.componentChannels) {
if (componentChannel === channel) {
channelStillInUse = true;
break;
}
}
// If no other components use this channel, disconnect the SSE client
if (!channelStillInUse) {
const sseClient = this.sseClients.get(channel);
if (sseClient) {
sseClient.disconnect();
this.sseClients.delete(channel);
console.log(`[LiveComponent] Disconnected SSE client for channel: ${channel}`);
}
}
}
/**
* Execute batch of operations
*
* Sends multiple component actions in a single HTTP request.
* Reduces HTTP overhead by 60-80% for multi-component updates.
*
* @param {Array<Object>} operations - Array of operation objects
* @param {Object} options - Batch options
* @returns {Promise<Object>} Batch response with results
*
* Example:
* await LiveComponent.executeBatch([
* {
* componentId: 'counter:demo',
* method: 'increment',
* params: { amount: 5 },
* fragments: ['counter-display'],
* operationId: 'op-1'
* },
* {
* componentId: 'user-stats:123',
* method: 'refresh',
* operationId: 'op-2'
* }
* ]);
*/
async executeBatch(operations, options = {}) {
if (!Array.isArray(operations) || operations.length === 0) {
throw new Error('Operations must be a non-empty array');
}
// Build batch request
const batchRequest = {
operations: operations.map(op => ({
componentId: op.componentId,
method: op.method,
params: op.params || {},
fragments: op.fragments || null,
operationId: op.operationId || null
}))
};
console.log(`[LiveComponent] Executing batch with ${operations.length} operations`, batchRequest);
try {
const response = await fetch('/live-component/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(batchRequest)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Batch request failed');
}
const batchResponse = await response.json();
console.log(`[LiveComponent] Batch completed: ${batchResponse.success_count} succeeded, ${batchResponse.failure_count} failed`);
// Process results and update components
if (options.autoApply !== false) {
await this.applyBatchResults(batchResponse.results);
}
return batchResponse;
} catch (error) {
console.error('[LiveComponent] Batch execution failed:', error);
throw error;
}
}
/**
* Apply batch results to components
*
* Updates component HTML/fragments and state based on batch response.
*
* @param {Array<Object>} results - Batch results array
*/
async applyBatchResults(results) {
for (const result of results) {
if (!result.success) {
console.warn(`[LiveComponent] Batch operation failed:`, result);
continue;
}
// Extract componentId from operationId or results
// (We need to track which component each result belongs to)
const componentId = result.componentId || this.extractComponentIdFromResult(result);
if (!componentId) continue;
const config = this.components.get(componentId);
if (!config) continue;
try {
// Update fragments if present
if (result.fragments) {
this.updateFragments(config.element, result.fragments);
}
// Update full HTML if present
else if (result.html) {
config.element.innerHTML = result.html;
this.setupActionHandlers(config.element);
}
// Update state
if (result.state) {
config.element.dataset.state = JSON.stringify(result.state);
}
// Dispatch events
if (result.events && Array.isArray(result.events)) {
result.events.forEach(event => {
this.dispatchComponentEvent(componentId, event.name, event.payload || {});
});
}
} catch (error) {
console.error(`[LiveComponent] Failed to apply batch result for ${componentId}:`, error);
}
}
}
/**
* Helper to extract component ID from batch result
* (Since we don't store componentId in result, we need to track it)
*/
extractComponentIdFromResult(result) {
// This is a placeholder - in production you'd track componentId per operation
// For now, we rely on the caller to provide componentId in custom data
return result.componentId || null;
}
/**
* Queue batch operation
*
* Allows batching multiple operations with automatic flush.
* Useful for collecting multiple updates that happen close together.
*
* @param {Object} operation - Operation to queue
* @param {Object} options - Queue options (flushDelay)
*/
queueBatchOperation(operation, options = {}) {
if (!this.batchQueue) {
this.batchQueue = [];
this.batchQueueTimer = null;
}
this.batchQueue.push(operation);
// Clear existing timer
if (this.batchQueueTimer) {
clearTimeout(this.batchQueueTimer);
}
// Auto-flush after delay
const flushDelay = options.flushDelay || 50; // 50ms default
this.batchQueueTimer = setTimeout(() => {
this.flushBatchQueue();
}, flushDelay);
}
/**
* Flush batch queue
*
* Executes all queued operations in a single batch request.
*/
async flushBatchQueue() {
if (!this.batchQueue || this.batchQueue.length === 0) {
return;
}
const operations = [...this.batchQueue];
this.batchQueue = [];
this.batchQueueTimer = null;
try {
await this.executeBatch(operations);
} catch (error) {
console.error('[LiveComponent] Batch queue flush failed:', error);
}
}
}
// Create global LiveComponent instance
const LiveComponent = new LiveComponentManager();
// Make LiveComponent globally accessible
window.LiveComponent = LiveComponent;
// Auto-initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-live-component]').forEach(el => {
LiveComponent.init(el);
});
});
}
// Framework module definition
export const definition = {
name: 'livecomponent',
version: '1.0.0',
dependencies: [],
provides: ['LiveComponent'],
priority: 0
};
// Framework module initialization (global setup)
export async function init(config = {}, state = {}) {
console.log('[LiveComponent] Module initializing...');
// Initialize Accessibility Manager (global live regions)
accessibilityManager.initialize();
// Initialize Lazy Loading system first
LiveComponent.initLazyLoading();
// Initialize Nested Components system
LiveComponent.initNestedComponents();
// Initialize all regular LiveComponents in the page
// This is needed when loaded as a core module (not via data-module attributes)
const components = document.querySelectorAll('[data-live-component]');
console.log(`[LiveComponent] Found ${components.length} regular components to initialize`);
components.forEach(el => {
LiveComponent.init(el);
});
// Lazy components are automatically initialized by LazyComponentLoader
const lazyComponents = document.querySelectorAll('[data-live-component-lazy]');
console.log(`[LiveComponent] Found ${lazyComponents.length} lazy components (will load on demand)`);
// Log nested component stats
if (LiveComponent.nestedHandler) {
const stats = LiveComponent.nestedHandler.getStats();
console.log(`[LiveComponent] Nested components: ${stats.child_components} children across ${stats.root_components} roots (max depth: ${stats.max_nesting_depth})`);
}
// Log accessibility stats
const a11yStats = accessibilityManager.getStats();
console.log(`[LiveComponent] Accessibility: ${a11yStats.component_live_regions} component regions, ${a11yStats.tracked_focus_states} tracked focus states`);
console.log('[LiveComponent] Module initialized');
return {
manager: LiveComponent,
state
};
}
// Framework DOM element initialization (called by framework for data-module elements)
export function initElement(element, options = {}) {
console.log('[LiveComponent] Initializing element with data-module:', element);
// Find all LiveComponents within this element (or the element itself)
const components = element.querySelectorAll('[data-live-component]');
if (components.length === 0 && element.hasAttribute('data-live-component')) {
// Element itself is a LiveComponent
LiveComponent.init(element);
} else {
// Initialize all child LiveComponents
components.forEach(el => LiveComponent.init(el));
}
return LiveComponent;
}
// Export LiveComponent class for direct use
export { LiveComponent };
export { ComponentPlayground };
export { ComponentFileUploader } from './ComponentFileUploader.js';
export { FileUploadWidget } from './FileUploadWidget.js';
export { ChunkedUploader } from './ChunkedUploader.js';
export default LiveComponent;