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.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -4,6 +4,15 @@ import { initApp } from './core/init.js';
import { Logger } from './core/logger.js';
import { CsrfAutoRefresh } from './modules/csrf-auto-refresh.js';
import { FormAutoSave } from './modules/form-autosave.js';
// WebPushManager is now loaded via framework module system
// LiveComponent is now loaded via framework module system
// Import DevTools in development
if (import.meta.env.DEV) {
import('./modules/LiveComponentDevTools.js').then(() => {
console.log('🛠️ LiveComponent DevTools loaded (Ctrl+Shift+D to toggle)');
});
}
// Import Hot Reload in development
if (import.meta.env.DEV) {
@@ -28,7 +37,10 @@ document.addEventListener("DOMContentLoaded", async () => {
// Initialize Form Auto-Save for all forms
const autosaveInstances = FormAutoSave.initializeAll();
console.log(`💾 Form Auto-Save initialized for ${autosaveInstances.length} forms`);
// WebPush Manager is initialized by framework module system
// Access via window.webPushManager after module initialization
// Debug info
setTimeout(() => {
console.log('📊 Debug Info:');

File diff suppressed because it is too large Load Diff

View File

@@ -21,5 +21,28 @@ export const moduleConfig = {
},
'smooth-scroll': {
'speed': 0.2,
},
'performance-profiler': {
enabled: false, // Enable via data-module="performance-profiler" or in dev mode
maxSamples: 1000,
samplingInterval: 10,
autoStart: false,
autoInstrument: true, // Auto-instrument LiveComponents
flamegraphContainer: '#flamegraph-container',
timelineContainer: '#timeline-container',
flamegraph: {
width: 1200,
height: 400,
barHeight: 20,
barPadding: 2,
colorScheme: 'category', // 'category', 'duration', 'monochrome'
minWidth: 0.5
},
timeline: {
width: 1200,
height: 200,
trackHeight: 30,
padding: { top: 20, right: 20, bottom: 30, left: 60 }
}
}
};

View File

@@ -1,26 +1,33 @@
/**
* CSRF Token Auto-Refresh System
*
*
* Automatically refreshes CSRF tokens before they expire to prevent
* form submission errors when users keep forms open for extended periods.
*
*
* Features:
* - Automatic token refresh every 105 minutes (15 minutes before expiry)
* - Support for both regular forms and LiveComponents
* - Visual feedback when tokens are refreshed
* - Graceful error handling with fallback strategies
* - Page visibility optimization (pause when tab is inactive)
* - Multiple form support
*
*
* Usage:
* import { CsrfAutoRefresh } from './modules/csrf-auto-refresh.js';
*
*
* // Initialize for contact form
* const csrfRefresh = new CsrfAutoRefresh({
* formId: 'contact_form',
* refreshInterval: 105 * 60 * 1000 // 105 minutes
* });
*
* // Or auto-detect all forms with CSRF tokens
*
* // Initialize for a LiveComponent
* const liveRefresh = new CsrfAutoRefresh({
* formId: 'counter:demo', // Component ID
* refreshInterval: 105 * 60 * 1000
* });
*
* // Or auto-detect all forms and LiveComponents with CSRF tokens
* CsrfAutoRefresh.initializeAll();
*/
@@ -185,11 +192,13 @@ export class CsrfAutoRefresh {
/**
* Update CSRF token in all forms on the page
* Supports both regular forms and LiveComponent data-csrf-token attributes
*/
updateTokenInForms(newToken) {
const tokenInputs = document.querySelectorAll(this.config.tokenSelector);
let updatedCount = 0;
// Update regular form input tokens
const tokenInputs = document.querySelectorAll(this.config.tokenSelector);
tokenInputs.forEach(input => {
// Check if this token belongs to our form
const form = input.closest('form');
@@ -202,10 +211,27 @@ export class CsrfAutoRefresh {
}
});
this.log(`Updated ${updatedCount} token input(s)`);
// Update LiveComponent data-csrf-token attributes
// LiveComponents use form ID format: "livecomponent:{componentId}"
const liveComponentFormId = 'livecomponent:' + this.config.formId.replace(/^livecomponent:/, '');
const liveComponents = document.querySelectorAll('[data-live-component][data-csrf-token]');
liveComponents.forEach(component => {
// Check if this component uses our form ID
const componentId = component.dataset.liveComponent;
const expectedFormId = 'livecomponent:' + componentId;
if (expectedFormId === liveComponentFormId || this.config.formId === componentId) {
component.dataset.csrfToken = newToken;
updatedCount++;
this.log(`Updated LiveComponent token: ${componentId}`);
}
});
this.log(`Updated ${updatedCount} token(s) (forms + LiveComponents)`);
if (updatedCount === 0) {
console.warn('CsrfAutoRefresh: No token inputs found to update. Check your selectors.');
console.warn('CsrfAutoRefresh: No tokens found to update. Check your selectors and formId.');
}
return updatedCount;
@@ -336,12 +362,13 @@ export class CsrfAutoRefresh {
/**
* Static method to initialize auto-refresh for all forms with CSRF tokens
* Supports both regular forms and LiveComponents
*/
static initializeAll() {
const tokenInputs = document.querySelectorAll('input[name="_token"]');
const formIds = new Set();
// Collect unique form IDs
// Collect unique form IDs from regular forms
const tokenInputs = document.querySelectorAll('input[name="_token"]');
tokenInputs.forEach(input => {
const form = input.closest('form');
if (form) {
@@ -352,14 +379,24 @@ export class CsrfAutoRefresh {
}
});
// Initialize auto-refresh for each unique form ID
// Collect unique component IDs from LiveComponents
const liveComponents = document.querySelectorAll('[data-live-component][data-csrf-token]');
liveComponents.forEach(component => {
const componentId = component.dataset.liveComponent;
if (componentId) {
// Use the component ID directly (without "livecomponent:" prefix for config)
formIds.add(componentId);
}
});
// Initialize auto-refresh for each unique form/component ID
const instances = [];
formIds.forEach(formId => {
const instance = new CsrfAutoRefresh({ formId });
instances.push(instance);
});
console.log(`CsrfAutoRefresh: Initialized for ${instances.length} forms:`, Array.from(formIds));
console.log(`CsrfAutoRefresh: Initialized for ${instances.length} forms/components:`, Array.from(formIds));
return instances;
}
}

View File

@@ -22,7 +22,7 @@ export async function registerModules() {
);
// Always load these core modules
const coreModules = new Set(['spa-router', 'form-handling', 'api-manager', 'image-manager']);
const coreModules = new Set(['spa-router', 'form-handling', 'api-manager', 'image-manager', 'livecomponent']);
const usedModules = new Set([...domModules, ...coreModules]);
const fallbackMode = usedModules.size === coreModules.size && domModules.size === 0;

View File

@@ -0,0 +1,453 @@
/**
* Accessibility Manager for LiveComponents
*
* Manages accessibility features for dynamic content updates:
* - ARIA live regions for screen reader announcements
* - Focus management with data-lc-keep-focus attribute
* - Keyboard navigation preservation
* - Screen reader-friendly update notifications
*
* WCAG 2.1 Compliance:
* - 4.1.3 Status Messages (Level AA)
* - 2.4.3 Focus Order (Level A)
* - 2.1.1 Keyboard (Level A)
*/
export class AccessibilityManager {
constructor() {
/**
* ARIA live region element for announcements
* @type {HTMLElement|null}
*/
this.liveRegion = null;
/**
* Component-specific live regions
* Map<componentId, HTMLElement>
*/
this.componentLiveRegions = new Map();
/**
* Focus tracking for restoration
* Map<componentId, FocusState>
*/
this.focusStates = new Map();
/**
* Announcement queue for throttling
* @type {Array<{message: string, priority: string}>}
*/
this.announcementQueue = [];
/**
* Announcement throttle timer
* @type {number|null}
*/
this.announceTimer = null;
/**
* Throttle delay in milliseconds
* @type {number}
*/
this.throttleDelay = 500;
}
/**
* Initialize accessibility features
*
* Creates global live region and sets up initial state.
*/
initialize() {
// Create global live region if not exists
if (!this.liveRegion) {
this.liveRegion = this.createLiveRegion('livecomponent-announcer', 'polite');
document.body.appendChild(this.liveRegion);
}
console.log('[AccessibilityManager] Initialized with ARIA live regions');
}
/**
* Create ARIA live region element
*
* @param {string} id - Element ID
* @param {string} politeness - ARIA politeness level (polite|assertive)
* @returns {HTMLElement} Live region element
*/
createLiveRegion(id, politeness = 'polite') {
const region = document.createElement('div');
region.id = id;
region.setAttribute('role', 'status');
region.setAttribute('aria-live', politeness);
region.setAttribute('aria-atomic', 'true');
region.className = 'sr-only'; // Screen reader only styling
// Add screen reader only styles
region.style.position = 'absolute';
region.style.left = '-10000px';
region.style.width = '1px';
region.style.height = '1px';
region.style.overflow = 'hidden';
return region;
}
/**
* Create component-specific live region
*
* Each component can have its own live region for isolated announcements.
*
* @param {string} componentId - Component identifier
* @param {HTMLElement} container - Component container element
* @param {string} politeness - ARIA politeness level
* @returns {HTMLElement} Component live region
*/
createComponentLiveRegion(componentId, container, politeness = 'polite') {
// Check if already exists
let liveRegion = this.componentLiveRegions.get(componentId);
if (!liveRegion) {
liveRegion = this.createLiveRegion(`livecomponent-${componentId}-announcer`, politeness);
container.appendChild(liveRegion);
this.componentLiveRegions.set(componentId, liveRegion);
}
return liveRegion;
}
/**
* Announce update to screen readers
*
* Uses ARIA live regions to announce updates without stealing focus.
* Supports throttling to prevent announcement spam.
*
* @param {string} message - Message to announce
* @param {string} priority - Priority level (polite|assertive)
* @param {string|null} componentId - Optional component-specific announcement
*/
announce(message, priority = 'polite', componentId = null) {
// Add to queue
this.announcementQueue.push({ message, priority, componentId });
// Throttle announcements
if (this.announceTimer) {
clearTimeout(this.announceTimer);
}
this.announceTimer = setTimeout(() => {
this.flushAnnouncements();
}, this.throttleDelay);
}
/**
* Flush announcement queue
*
* Processes queued announcements and clears the queue.
*/
flushAnnouncements() {
if (this.announcementQueue.length === 0) return;
// Get most recent announcement (others are outdated)
const announcement = this.announcementQueue[this.announcementQueue.length - 1];
this.announcementQueue = [];
// Determine target live region
let liveRegion = this.liveRegion;
if (announcement.componentId) {
const componentRegion = this.componentLiveRegions.get(announcement.componentId);
if (componentRegion) {
liveRegion = componentRegion;
}
}
if (!liveRegion) {
console.warn('[AccessibilityManager] No live region available for announcement');
return;
}
// Update politeness if needed
if (announcement.priority === 'assertive') {
liveRegion.setAttribute('aria-live', 'assertive');
} else {
liveRegion.setAttribute('aria-live', 'polite');
}
// Clear and set new message
liveRegion.textContent = '';
// Use setTimeout to ensure screen reader picks up the change
setTimeout(() => {
liveRegion.textContent = announcement.message;
}, 100);
console.log(`[AccessibilityManager] Announced: "${announcement.message}" (${announcement.priority})`);
}
/**
* Capture focus state before update
*
* Saves current focus state for potential restoration after update.
*
* @param {string} componentId - Component identifier
* @param {HTMLElement} container - Component container element
* @returns {FocusState} Focus state object
*/
captureFocusState(componentId, container) {
const activeElement = document.activeElement;
// Check if focus is within container
if (!activeElement || !container.contains(activeElement)) {
return null;
}
const focusState = {
selector: this.getElementSelector(activeElement, container),
tagName: activeElement.tagName,
name: activeElement.name || null,
id: activeElement.id || null,
selectionStart: activeElement.selectionStart || null,
selectionEnd: activeElement.selectionEnd || null,
scrollTop: activeElement.scrollTop || 0,
scrollLeft: activeElement.scrollLeft || 0,
keepFocus: activeElement.hasAttribute('data-lc-keep-focus')
};
this.focusStates.set(componentId, focusState);
console.log(`[AccessibilityManager] Captured focus state for ${componentId}`, focusState);
return focusState;
}
/**
* Restore focus after update
*
* Restores focus to element marked with data-lc-keep-focus or previously focused element.
*
* @param {string} componentId - Component identifier
* @param {HTMLElement} container - Component container element
* @returns {boolean} True if focus was restored
*/
restoreFocus(componentId, container) {
const focusState = this.focusStates.get(componentId);
if (!focusState) {
return false;
}
try {
// Find element to focus
let elementToFocus = null;
// Priority 1: Element with data-lc-keep-focus attribute
if (focusState.keepFocus) {
elementToFocus = container.querySelector('[data-lc-keep-focus]');
}
// Priority 2: Element matching saved selector
if (!elementToFocus && focusState.selector) {
elementToFocus = container.querySelector(focusState.selector);
}
// Priority 3: Element with same ID
if (!elementToFocus && focusState.id) {
elementToFocus = container.querySelector(`#${focusState.id}`);
}
// Priority 4: Element with same name
if (!elementToFocus && focusState.name) {
elementToFocus = container.querySelector(`[name="${focusState.name}"]`);
}
if (elementToFocus && elementToFocus.focus) {
elementToFocus.focus();
// Restore selection for inputs/textareas
if (elementToFocus.setSelectionRange &&
focusState.selectionStart !== null &&
focusState.selectionEnd !== null) {
elementToFocus.setSelectionRange(
focusState.selectionStart,
focusState.selectionEnd
);
}
// Restore scroll position
if (focusState.scrollTop > 0) {
elementToFocus.scrollTop = focusState.scrollTop;
}
if (focusState.scrollLeft > 0) {
elementToFocus.scrollLeft = focusState.scrollLeft;
}
console.log(`[AccessibilityManager] Restored focus to ${focusState.selector}`);
return true;
}
} catch (error) {
console.debug('[AccessibilityManager] Could not restore focus:', error);
} finally {
// Clean up focus state
this.focusStates.delete(componentId);
}
return false;
}
/**
* Get CSS selector for an element within a container
*
* @param {HTMLElement} element - Element to get selector for
* @param {HTMLElement} container - Container element
* @returns {string|null} CSS selector or null
*/
getElementSelector(element, container) {
// Try ID (most specific)
if (element.id) {
return `#${element.id}`;
}
// Try name attribute
if (element.name) {
return `[name="${element.name}"]`;
}
// Try data-lc-key
const lcKey = element.getAttribute('data-lc-key');
if (lcKey) {
return `[data-lc-key="${lcKey}"]`;
}
// Try to build a path from container
const path = [];
let current = element;
while (current && current !== container && current !== document.body) {
let selector = current.tagName.toLowerCase();
// Add class if available
if (current.className && typeof current.className === 'string') {
const classes = current.className.split(' ').filter(c => c.trim());
if (classes.length > 0) {
selector += '.' + classes.join('.');
}
}
// Add nth-child if needed for specificity
if (current.parentElement) {
const siblings = Array.from(current.parentElement.children);
const index = siblings.indexOf(current) + 1;
if (siblings.length > 1) {
selector += `:nth-child(${index})`;
}
}
path.unshift(selector);
current = current.parentElement;
}
return path.length > 0 ? path.join(' > ') : null;
}
/**
* Announce component update
*
* Convenience method for announcing component updates.
*
* @param {string} componentId - Component identifier
* @param {string} updateType - Type of update (fragment|full|action)
* @param {Object} metadata - Additional metadata
*/
announceUpdate(componentId, updateType, metadata = {}) {
let message = '';
switch (updateType) {
case 'fragment':
message = `Updated ${metadata.fragmentName || 'content'}`;
break;
case 'full':
message = 'Component updated';
break;
case 'action':
message = metadata.actionMessage || 'Action completed';
break;
default:
message = 'Content updated';
}
this.announce(message, 'polite', componentId);
}
/**
* Check if element should preserve keyboard navigation
*
* @param {HTMLElement} element - Element to check
* @returns {boolean} True if keyboard navigation should be preserved
*/
shouldPreserveKeyboardNav(element) {
// Interactive elements that should always preserve keyboard nav
const interactiveTags = ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'];
if (interactiveTags.includes(element.tagName)) {
return true;
}
// Elements with tabindex
if (element.hasAttribute('tabindex')) {
return true;
}
// Elements with role
const interactiveRoles = [
'button', 'link', 'textbox', 'searchbox',
'combobox', 'listbox', 'option', 'tab'
];
const role = element.getAttribute('role');
if (role && interactiveRoles.includes(role)) {
return true;
}
return false;
}
/**
* Cleanup component accessibility features
*
* @param {string} componentId - Component identifier
*/
cleanup(componentId) {
// Remove component live region
const liveRegion = this.componentLiveRegions.get(componentId);
if (liveRegion && liveRegion.parentElement) {
liveRegion.parentElement.removeChild(liveRegion);
}
this.componentLiveRegions.delete(componentId);
// Clear focus state
this.focusStates.delete(componentId);
console.log(`[AccessibilityManager] Cleaned up accessibility for ${componentId}`);
}
/**
* Get accessibility stats
*
* @returns {Object} Statistics about accessibility manager state
*/
getStats() {
return {
has_global_live_region: this.liveRegion !== null,
component_live_regions: this.componentLiveRegions.size,
tracked_focus_states: this.focusStates.size,
pending_announcements: this.announcementQueue.length
};
}
}
// Create singleton instance
export const accessibilityManager = new AccessibilityManager();
export default accessibilityManager;

View File

@@ -0,0 +1,701 @@
/**
* ChunkedUploader - Chunked File Upload Module for Large Files
*
* Features:
* - Break large files into manageable chunks
* - SHA-256 hashing for integrity verification
* - Resume capability for interrupted uploads
* - Real-time progress via SSE
* - Retry logic with exponential backoff
* - Parallel chunk uploads (configurable)
* - Integration with LiveComponent system
*
* @package Framework\LiveComponents
*/
import { getGlobalSseClient } from '../sse/index.js';
/**
* Chunk Upload Status
*/
const ChunkStatus = {
PENDING: 'pending',
HASHING: 'hashing',
UPLOADING: 'uploading',
COMPLETE: 'complete',
ERROR: 'error'
};
/**
* Upload Session Status
*/
const SessionStatus = {
INITIALIZING: 'initializing',
INITIALIZED: 'initialized',
UPLOADING: 'uploading',
ASSEMBLING: 'assembling',
COMPLETE: 'complete',
ABORTED: 'aborted',
ERROR: 'error'
};
/**
* Chunk Metadata - Tracks individual chunk state
*/
class ChunkMetadata {
constructor(index, size, file) {
this.index = index;
this.size = size;
this.status = ChunkStatus.PENDING;
this.hash = null;
this.uploadedBytes = 0;
this.retries = 0;
this.error = null;
this.xhr = null;
this.file = file;
}
get progress() {
return this.size > 0 ? (this.uploadedBytes / this.size) * 100 : 0;
}
reset() {
this.status = ChunkStatus.PENDING;
this.uploadedBytes = 0;
this.error = null;
this.xhr = null;
}
abort() {
if (this.xhr) {
this.xhr.abort();
this.xhr = null;
}
}
}
/**
* Upload Session - Manages complete chunked upload session
*/
class UploadSession {
constructor(file, options) {
this.file = file;
this.sessionId = null;
this.totalChunks = 0;
this.chunkSize = options.chunkSize;
this.chunks = [];
this.status = SessionStatus.INITIALIZING;
this.uploadedChunks = 0;
this.expectedFileHash = null;
this.error = null;
this.startTime = null;
this.endTime = null;
this.expiresAt = null;
}
get progress() {
if (this.totalChunks === 0) return 0;
return (this.uploadedChunks / this.totalChunks) * 100;
}
get uploadedBytes() {
return this.chunks.reduce((sum, chunk) => sum + chunk.uploadedBytes, 0);
}
get totalBytes() {
return this.file.size;
}
get isComplete() {
return this.status === SessionStatus.COMPLETE;
}
get isError() {
return this.status === SessionStatus.ERROR;
}
get isAborted() {
return this.status === SessionStatus.ABORTED;
}
get canResume() {
return this.sessionId !== null && !this.isComplete && !this.isAborted;
}
getChunk(index) {
return this.chunks[index];
}
getPendingChunks() {
return this.chunks.filter(c => c.status === ChunkStatus.PENDING || c.status === ChunkStatus.ERROR);
}
getUploadingChunks() {
return this.chunks.filter(c => c.status === ChunkStatus.UPLOADING);
}
}
/**
* ChunkedUploader - Main chunked upload manager
*/
export class ChunkedUploader {
constructor(componentId, options = {}) {
this.componentId = componentId;
// Options
this.chunkSize = options.chunkSize || 512 * 1024; // 512KB default
this.maxConcurrentChunks = options.maxConcurrentChunks || 3;
this.maxRetries = options.maxRetries || 3;
this.retryDelay = options.retryDelay || 1000; // 1s base delay
this.enableSSE = options.enableSSE !== false; // SSE enabled by default
this.apiBase = options.apiBase || '/live-component/upload';
// Callbacks
this.onInitialized = options.onInitialized || (() => {});
this.onChunkProgress = options.onChunkProgress || (() => {});
this.onProgress = options.onProgress || (() => {});
this.onComplete = options.onComplete || (() => {});
this.onError = options.onError || (() => {});
this.onAborted = options.onAborted || (() => {});
this.onSSEProgress = options.onSSEProgress || (() => {});
// State
this.sessions = new Map(); // sessionId => UploadSession
this.activeSession = null;
this.sseClient = null;
this.userId = null;
// Initialize SSE if enabled
if (this.enableSSE) {
this.initializeSSE();
}
}
/**
* Initialize SSE connection for real-time progress
*/
initializeSSE() {
try {
// Get user ID from meta tag or data attribute
this.userId = this.getUserId();
if (this.userId) {
this.sseClient = getGlobalSseClient([`user:${this.userId}`]);
// Listen for progress events
this.sseClient.on('progress', (data) => {
this.handleSSEProgress(data);
});
// Connect if not already connected
if (!this.sseClient.isConnected()) {
this.sseClient.connect();
}
}
} catch (error) {
console.warn('[ChunkedUploader] Failed to initialize SSE:', error);
}
}
/**
* Get user ID for SSE channel
*/
getUserId() {
// Try meta tag first
const meta = document.querySelector('meta[name="user-id"]');
if (meta) return meta.content;
// Try data attribute on body
if (document.body.dataset.userId) {
return document.body.dataset.userId;
}
return null;
}
/**
* Handle SSE progress updates
*/
handleSSEProgress(data) {
const sessionId = data.session_id;
const session = this.sessions.get(sessionId);
if (session && data.taskId === sessionId) {
// Update session from SSE data
if (data.data?.uploaded_chunks !== undefined) {
session.uploadedChunks = data.data.uploaded_chunks;
}
if (data.data?.phase) {
switch (data.data.phase) {
case 'initialized':
session.status = SessionStatus.INITIALIZED;
break;
case 'uploading':
session.status = SessionStatus.UPLOADING;
break;
case 'completed':
session.status = SessionStatus.COMPLETE;
break;
case 'aborted':
session.status = SessionStatus.ABORTED;
break;
case 'error':
session.status = SessionStatus.ERROR;
break;
}
}
// Callback
this.onSSEProgress({
sessionId,
percent: data.percent,
message: data.message,
data: data.data
});
}
}
/**
* Upload file with chunking
*
* @param {File} file - File to upload
* @param {string} targetPath - Target path for assembled file
* @returns {Promise<UploadSession>}
*/
async upload(file, targetPath) {
// Create upload session
const session = new UploadSession(file, {
chunkSize: this.chunkSize
});
this.activeSession = session;
try {
// Calculate chunks
session.totalChunks = Math.ceil(file.size / this.chunkSize);
for (let i = 0; i < session.totalChunks; i++) {
const start = i * this.chunkSize;
const end = Math.min(start + this.chunkSize, file.size);
const size = end - start;
session.chunks.push(new ChunkMetadata(i, size, file));
}
// Initialize session with API
await this.initializeSession(session);
// Store session
this.sessions.set(session.sessionId, session);
// Start uploading chunks
session.status = SessionStatus.UPLOADING;
session.startTime = Date.now();
await this.uploadChunks(session);
// Complete upload
await this.completeUpload(session, targetPath);
// Success
session.status = SessionStatus.COMPLETE;
session.endTime = Date.now();
this.onComplete({
sessionId: session.sessionId,
file: session.file,
totalBytes: session.totalBytes,
duration: session.endTime - session.startTime
});
return session;
} catch (error) {
session.status = SessionStatus.ERROR;
session.error = error.message;
this.onError({
sessionId: session.sessionId,
file: session.file,
error: error.message
});
throw error;
}
}
/**
* Initialize upload session with API
*/
async initializeSession(session) {
const response = await fetch(`${this.apiBase}/init`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': navigator.userAgent
},
body: JSON.stringify({
componentId: this.componentId,
fileName: session.file.name,
totalSize: session.file.size,
chunkSize: this.chunkSize
})
});
if (!response.ok) {
throw new Error(`Failed to initialize upload session: ${response.status}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to initialize upload session');
}
// Update session
session.sessionId = data.session_id;
session.totalChunks = data.total_chunks;
session.expiresAt = new Date(data.expires_at);
session.status = SessionStatus.INITIALIZED;
this.onInitialized({
sessionId: session.sessionId,
totalChunks: session.totalChunks,
expiresAt: session.expiresAt
});
}
/**
* Upload all chunks with parallelization
*/
async uploadChunks(session) {
const pending = session.getPendingChunks();
// Upload chunks in parallel batches
while (pending.length > 0) {
const batch = pending.splice(0, this.maxConcurrentChunks);
await Promise.all(
batch.map(chunk => this.uploadChunk(session, chunk))
);
}
}
/**
* Upload a single chunk
*/
async uploadChunk(session, chunk) {
let retries = 0;
while (retries <= this.maxRetries) {
try {
// Hash chunk data
chunk.status = ChunkStatus.HASHING;
const chunkData = await this.readChunk(session.file, chunk);
const chunkHash = await this.hashChunk(chunkData);
chunk.hash = chunkHash;
// Upload chunk
chunk.status = ChunkStatus.UPLOADING;
await this.uploadChunkData(session, chunk, chunkData, chunkHash);
// Success
chunk.status = ChunkStatus.COMPLETE;
session.uploadedChunks++;
this.onProgress({
sessionId: session.sessionId,
progress: session.progress,
uploadedChunks: session.uploadedChunks,
totalChunks: session.totalChunks,
uploadedBytes: session.uploadedBytes,
totalBytes: session.totalBytes
});
return;
} catch (error) {
retries++;
if (retries > this.maxRetries) {
chunk.status = ChunkStatus.ERROR;
chunk.error = error.message;
throw new Error(`Chunk ${chunk.index} failed after ${this.maxRetries} retries: ${error.message}`);
}
// Exponential backoff
const delay = this.retryDelay * Math.pow(2, retries - 1);
await this.sleep(delay);
chunk.reset();
}
}
}
/**
* Read chunk data from file
*/
readChunk(file, chunk) {
return new Promise((resolve, reject) => {
const start = chunk.index * this.chunkSize;
const end = Math.min(start + chunk.size, file.size);
const blob = file.slice(start, end);
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = (e) => reject(new Error('Failed to read chunk'));
reader.readAsArrayBuffer(blob);
});
}
/**
* Hash chunk data with SHA-256
*/
async hashChunk(chunkData) {
const hashBuffer = await crypto.subtle.digest('SHA-256', chunkData);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Upload chunk data to server
*/
uploadChunkData(session, chunk, chunkData, chunkHash) {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('sessionId', session.sessionId);
formData.append('chunkIndex', chunk.index);
formData.append('chunkHash', chunkHash);
formData.append('chunk', new Blob([chunkData]));
const xhr = new XMLHttpRequest();
chunk.xhr = xhr;
// Progress tracking
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
chunk.uploadedBytes = e.loaded;
this.onChunkProgress({
sessionId: session.sessionId,
chunkIndex: chunk.index,
uploadedBytes: e.loaded,
totalBytes: e.total,
progress: (e.loaded / e.total) * 100
});
}
});
// Load event
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
resolve(response);
} else {
reject(new Error(response.error || 'Chunk upload failed'));
}
} catch (e) {
reject(e);
}
} else {
reject(new Error(`Chunk upload failed with status ${xhr.status}`));
}
});
// Error events
xhr.addEventListener('error', () => {
reject(new Error('Network error during chunk upload'));
});
xhr.addEventListener('abort', () => {
reject(new Error('Chunk upload cancelled'));
});
// Send request
xhr.open('POST', `${this.apiBase}/chunk`);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('User-Agent', navigator.userAgent);
xhr.send(formData);
});
}
/**
* Complete upload and assemble file
*/
async completeUpload(session, targetPath) {
session.status = SessionStatus.ASSEMBLING;
const response = await fetch(`${this.apiBase}/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': navigator.userAgent
},
body: JSON.stringify({
sessionId: session.sessionId,
targetPath: targetPath
})
});
if (!response.ok) {
throw new Error(`Failed to complete upload: ${response.status}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to complete upload');
}
return data;
}
/**
* Abort upload
*/
async abort(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error('Session not found');
}
// Abort all active chunk uploads
session.chunks.forEach(chunk => chunk.abort());
// Notify server
try {
await fetch(`${this.apiBase}/abort`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': navigator.userAgent
},
body: JSON.stringify({
sessionId: sessionId,
reason: 'User cancelled'
})
});
} catch (error) {
console.warn('[ChunkedUploader] Failed to notify server of abort:', error);
}
// Update session
session.status = SessionStatus.ABORTED;
this.onAborted({
sessionId,
uploadedChunks: session.uploadedChunks,
totalChunks: session.totalChunks
});
}
/**
* Get upload status
*/
async getStatus(sessionId) {
const response = await fetch(`${this.apiBase}/status/${sessionId}`, {
headers: {
'Accept': 'application/json',
'User-Agent': navigator.userAgent
}
});
if (!response.ok) {
throw new Error(`Failed to get upload status: ${response.status}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to get upload status');
}
return data;
}
/**
* Resume interrupted upload
*/
async resume(sessionId, file, targetPath) {
// Get current status from server
const status = await this.getStatus(sessionId);
// Recreate session
const session = new UploadSession(file, {
chunkSize: this.chunkSize
});
session.sessionId = sessionId;
session.totalChunks = status.total_chunks;
session.uploadedChunks = status.uploaded_chunks;
session.status = SessionStatus.UPLOADING;
// Recreate chunks
for (let i = 0; i < session.totalChunks; i++) {
const start = i * this.chunkSize;
const end = Math.min(start + this.chunkSize, file.size);
const size = end - start;
const chunk = new ChunkMetadata(i, size, file);
// Mark already uploaded chunks as complete
if (i < status.uploaded_chunks) {
chunk.status = ChunkStatus.COMPLETE;
chunk.uploadedBytes = chunk.size;
}
session.chunks.push(chunk);
}
// Store and set as active
this.sessions.set(sessionId, session);
this.activeSession = session;
// Resume uploading
session.startTime = Date.now();
await this.uploadChunks(session);
await this.completeUpload(session, targetPath);
session.status = SessionStatus.COMPLETE;
session.endTime = Date.now();
this.onComplete({
sessionId: session.sessionId,
file: session.file,
totalBytes: session.totalBytes,
duration: session.endTime - session.startTime,
resumed: true
});
return session;
}
/**
* Helper: Sleep for delay
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Cleanup
*/
destroy() {
// Abort all active sessions
this.sessions.forEach(session => {
if (session.canResume) {
session.chunks.forEach(chunk => chunk.abort());
}
});
this.sessions.clear();
this.activeSession = null;
}
}
export default ChunkedUploader;

View File

@@ -0,0 +1,638 @@
/**
* ComponentFileUploader - File Upload Module for LiveComponents
*
* Features:
* - Drag & Drop support
* - Multi-file uploads with queue management
* - Progress tracking (per-file and overall)
* - Image/document previews
* - Client-side validation
* - CSRF protection
* - Integration with LiveComponent state management
*
* @package Framework\LiveComponents
*/
/**
* File Upload Progress Tracker
*/
export class UploadProgress {
constructor(file, fileId) {
this.file = file;
this.fileId = fileId;
this.uploadedBytes = 0;
this.totalBytes = file.size;
this.status = 'pending'; // pending, uploading, processing, complete, error
this.error = null;
this.xhr = null;
this.startTime = null;
this.endTime = null;
}
get percentage() {
if (this.totalBytes === 0) return 100;
return Math.round((this.uploadedBytes / this.totalBytes) * 100);
}
get isComplete() {
return this.status === 'complete';
}
get hasError() {
return this.status === 'error';
}
get isUploading() {
return this.status === 'uploading';
}
get uploadSpeed() {
if (!this.startTime || !this.isUploading) return 0;
const elapsed = (Date.now() - this.startTime) / 1000; // seconds
return elapsed > 0 ? this.uploadedBytes / elapsed : 0; // bytes per second
}
get remainingTime() {
const speed = this.uploadSpeed;
if (speed === 0) return 0;
const remaining = this.totalBytes - this.uploadedBytes;
return remaining / speed; // seconds
}
updateProgress(loaded, total) {
this.uploadedBytes = loaded;
this.totalBytes = total;
}
setStatus(status, error = null) {
this.status = status;
this.error = error;
if (status === 'uploading' && !this.startTime) {
this.startTime = Date.now();
}
if (status === 'complete' || status === 'error') {
this.endTime = Date.now();
}
}
abort() {
if (this.xhr) {
this.xhr.abort();
this.setStatus('error', 'Upload cancelled');
}
}
toObject() {
return {
fileId: this.fileId,
fileName: this.file.name,
fileSize: this.totalBytes,
uploadedBytes: this.uploadedBytes,
percentage: this.percentage,
status: this.status,
error: this.error,
uploadSpeed: this.uploadSpeed,
remainingTime: this.remainingTime
};
}
}
/**
* File Validator - Client-side validation
*/
export class FileValidator {
constructor(options = {}) {
this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB default
this.allowedMimeTypes = options.allowedMimeTypes || [];
this.allowedExtensions = options.allowedExtensions || [];
this.minFileSize = options.minFileSize || 1; // 1 byte minimum
}
validate(file) {
const errors = [];
// File size validation
if (file.size > this.maxFileSize) {
errors.push(`File size (${this.formatBytes(file.size)}) exceeds maximum allowed size (${this.formatBytes(this.maxFileSize)})`);
}
if (file.size < this.minFileSize) {
errors.push(`File size is too small (minimum: ${this.formatBytes(this.minFileSize)})`);
}
// MIME type validation
if (this.allowedMimeTypes.length > 0 && !this.allowedMimeTypes.includes(file.type)) {
errors.push(`File type "${file.type}" is not allowed. Allowed types: ${this.allowedMimeTypes.join(', ')}`);
}
// Extension validation
if (this.allowedExtensions.length > 0) {
const extension = file.name.split('.').pop().toLowerCase();
if (!this.allowedExtensions.includes(extension)) {
errors.push(`File extension ".${extension}" is not allowed. Allowed extensions: ${this.allowedExtensions.join(', ')}`);
}
}
// File name validation
if (file.name.length > 255) {
errors.push('File name is too long (maximum 255 characters)');
}
return errors;
}
isValid(file) {
return this.validate(file).length === 0;
}
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
}
/**
* Drag & Drop Zone Manager
*/
export class DragDropZone {
constructor(element, callbacks = {}) {
this.element = element;
this.onFilesDropped = callbacks.onFilesDropped || (() => {});
this.onDragEnter = callbacks.onDragEnter || (() => {});
this.onDragLeave = callbacks.onDragLeave || (() => {});
this.dragCounter = 0; // Track nested drag events
this.isActive = false;
this.bindEvents();
}
bindEvents() {
// Prevent default browser behavior for drag events
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
this.element.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
});
});
// Handle drag enter
this.element.addEventListener('dragenter', (e) => {
this.dragCounter++;
if (this.dragCounter === 1) {
this.isActive = true;
this.element.classList.add('drag-over');
this.onDragEnter(e);
}
});
// Handle drag leave
this.element.addEventListener('dragleave', (e) => {
this.dragCounter--;
if (this.dragCounter === 0) {
this.isActive = false;
this.element.classList.remove('drag-over');
this.onDragLeave(e);
}
});
// Handle drag over
this.element.addEventListener('dragover', (e) => {
// Required to allow drop
});
// Handle drop
this.element.addEventListener('drop', (e) => {
this.dragCounter = 0;
this.isActive = false;
this.element.classList.remove('drag-over');
const files = Array.from(e.dataTransfer?.files || []);
if (files.length > 0) {
this.onFilesDropped(files);
}
});
}
destroy() {
this.element.classList.remove('drag-over');
// Event listeners are automatically removed when element is removed
}
}
/**
* Main ComponentFileUploader Class
*/
export class ComponentFileUploader {
constructor(componentElement, options = {}) {
this.componentElement = componentElement;
this.componentId = componentElement.dataset.liveId;
// Options
this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB
this.allowedMimeTypes = options.allowedMimeTypes || [];
this.allowedExtensions = options.allowedExtensions || [];
this.maxFiles = options.maxFiles || 10;
this.autoUpload = options.autoUpload !== false; // default: true
this.multiple = options.multiple !== false; // default: true
this.endpoint = options.endpoint || `/live-component/${this.componentId}/upload`;
// Callbacks
this.onFileAdded = options.onFileAdded || (() => {});
this.onFileRemoved = options.onFileRemoved || (() => {});
this.onUploadStart = options.onUploadStart || (() => {});
this.onUploadProgress = options.onUploadProgress || (() => {});
this.onUploadComplete = options.onUploadComplete || (() => {});
this.onUploadError = options.onUploadError || (() => {});
this.onAllUploadsComplete = options.onAllUploadsComplete || (() => {});
// State
this.files = new Map(); // Map<fileId, UploadProgress>
this.uploadQueue = [];
this.activeUploads = 0;
this.maxConcurrentUploads = options.maxConcurrentUploads || 2;
// Validator
this.validator = new FileValidator({
maxFileSize: this.maxFileSize,
allowedMimeTypes: this.allowedMimeTypes,
allowedExtensions: this.allowedExtensions
});
// UI Elements (optional)
this.dropZoneElement = options.dropZone;
this.fileInputElement = options.fileInput;
this.initialize();
}
initialize() {
// Setup drag & drop if dropZone provided
if (this.dropZoneElement) {
this.dragDropZone = new DragDropZone(this.dropZoneElement, {
onFilesDropped: (files) => this.addFiles(files),
onDragEnter: () => this.dropZoneElement.classList.add('drag-active'),
onDragLeave: () => this.dropZoneElement.classList.remove('drag-active')
});
}
// Setup file input if provided
if (this.fileInputElement) {
this.fileInputElement.addEventListener('change', (e) => {
const files = Array.from(e.target.files || []);
this.addFiles(files);
e.target.value = ''; // Reset input
});
}
}
/**
* Add files to upload queue
*/
addFiles(files) {
const filesToAdd = Array.isArray(files) ? files : [files];
// Check max files limit
if (this.files.size + filesToAdd.length > this.maxFiles) {
const error = `Cannot add files. Maximum ${this.maxFiles} files allowed.`;
this.onUploadError({ error, fileCount: filesToAdd.length });
return;
}
for (const file of filesToAdd) {
// Generate unique file ID
const fileId = this.generateFileId(file);
// Validate file
const validationErrors = this.validator.validate(file);
const progress = new UploadProgress(file, fileId);
if (validationErrors.length > 0) {
progress.setStatus('error', validationErrors.join(', '));
this.files.set(fileId, progress);
this.onUploadError({
fileId,
file,
errors: validationErrors
});
continue;
}
// Add to files map
this.files.set(fileId, progress);
this.uploadQueue.push(fileId);
// Callback
this.onFileAdded({
fileId,
file,
progress: progress.toObject()
});
}
// Auto-upload if enabled
if (this.autoUpload) {
this.processQueue();
}
}
/**
* Remove file from queue
*/
removeFile(fileId) {
const progress = this.files.get(fileId);
if (!progress) return;
// Abort if uploading
if (progress.isUploading) {
progress.abort();
}
// Remove from queue
const queueIndex = this.uploadQueue.indexOf(fileId);
if (queueIndex !== -1) {
this.uploadQueue.splice(queueIndex, 1);
}
// Remove from files map
this.files.delete(fileId);
// Callback
this.onFileRemoved({ fileId, file: progress.file });
}
/**
* Start uploading all queued files
*/
uploadAll() {
this.processQueue();
}
/**
* Process upload queue
*/
async processQueue() {
while (this.uploadQueue.length > 0 && this.activeUploads < this.maxConcurrentUploads) {
const fileId = this.uploadQueue.shift();
const progress = this.files.get(fileId);
if (!progress || progress.status !== 'pending') continue;
this.activeUploads++;
this.uploadFile(fileId).finally(() => {
this.activeUploads--;
this.processQueue(); // Process next file
// Check if all uploads are complete
if (this.activeUploads === 0 && this.uploadQueue.length === 0) {
this.onAllUploadsComplete({
totalFiles: this.files.size,
successCount: Array.from(this.files.values()).filter(p => p.isComplete).length,
errorCount: Array.from(this.files.values()).filter(p => p.hasError).length
});
}
});
}
}
/**
* Upload a single file
*/
async uploadFile(fileId) {
const progress = this.files.get(fileId);
if (!progress) return;
try {
// Get current component state
const componentState = this.getComponentState();
// Get CSRF tokens
const csrfTokens = await this.getCsrfTokens();
// Create FormData
const formData = new FormData();
formData.append('file', progress.file);
formData.append('state', JSON.stringify(componentState));
formData.append('params', JSON.stringify({ fileId }));
// Create XHR request
const xhr = new XMLHttpRequest();
progress.xhr = xhr;
// Setup progress tracking
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
progress.updateProgress(e.loaded, e.total);
this.onUploadProgress({
fileId,
...progress.toObject()
});
}
});
// Setup completion handler
const uploadPromise = new Promise((resolve, reject) => {
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
progress.setStatus('complete');
// Update component state if provided
if (response.state) {
this.updateComponentState(response.state);
}
// Update component HTML if provided
if (response.html) {
this.updateComponentHtml(response.html);
}
this.onUploadComplete({
fileId,
file: progress.file,
response
});
resolve(response);
} else {
throw new Error(response.error || 'Upload failed');
}
} catch (e) {
reject(e);
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Network error during upload'));
});
xhr.addEventListener('abort', () => {
reject(new Error('Upload cancelled'));
});
});
// Set status to uploading
progress.setStatus('uploading');
this.onUploadStart({ fileId, file: progress.file });
// Open and send request
xhr.open('POST', this.endpoint);
// Set CSRF headers
xhr.setRequestHeader('X-CSRF-Form-ID', csrfTokens.form_id);
xhr.setRequestHeader('X-CSRF-Token', csrfTokens.token);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('User-Agent', navigator.userAgent);
xhr.send(formData);
await uploadPromise;
} catch (error) {
progress.setStatus('error', error.message);
this.onUploadError({
fileId,
file: progress.file,
error: error.message
});
}
}
/**
* Cancel all uploads
*/
cancelAll() {
this.uploadQueue = [];
this.files.forEach(progress => {
if (progress.isUploading) {
progress.abort();
}
});
}
/**
* Clear all files (completed and pending)
*/
clearAll() {
this.cancelAll();
this.files.clear();
}
/**
* Get overall upload progress
*/
getOverallProgress() {
if (this.files.size === 0) return 100;
const totalBytes = Array.from(this.files.values()).reduce((sum, p) => sum + p.totalBytes, 0);
const uploadedBytes = Array.from(this.files.values()).reduce((sum, p) => sum + p.uploadedBytes, 0);
return totalBytes > 0 ? Math.round((uploadedBytes / totalBytes) * 100) : 0;
}
/**
* Get upload statistics
*/
getStats() {
const files = Array.from(this.files.values());
return {
total: files.length,
pending: files.filter(p => p.status === 'pending').length,
uploading: files.filter(p => p.status === 'uploading').length,
complete: files.filter(p => p.status === 'complete').length,
error: files.filter(p => p.status === 'error').length,
overallProgress: this.getOverallProgress()
};
}
/**
* Helper: Generate unique file ID
*/
generateFileId(file) {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${file.name}`;
}
/**
* Helper: Get CSRF tokens
*/
async getCsrfTokens() {
try {
const response = await fetch(`/api/csrf/token?action=${encodeURIComponent(this.endpoint)}&method=post`, {
headers: {
'Accept': 'application/json',
'User-Agent': navigator.userAgent
}
});
if (!response.ok) {
throw new Error(`CSRF token request failed: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to get CSRF tokens:', error);
throw error;
}
}
/**
* Helper: Get component state from DOM
*/
getComponentState() {
const stateElement = this.componentElement.querySelector('[data-live-state]');
if (stateElement) {
try {
return JSON.parse(stateElement.textContent || '{}');
} catch (e) {
console.warn('Failed to parse component state:', e);
}
}
return {};
}
/**
* Helper: Update component state in DOM
*/
updateComponentState(newState) {
const stateElement = this.componentElement.querySelector('[data-live-state]');
if (stateElement) {
stateElement.textContent = JSON.stringify(newState);
}
}
/**
* Helper: Update component HTML
*/
updateComponentHtml(html) {
// Find the component content container (usually the component itself)
const contentElement = this.componentElement.querySelector('[data-live-content]') || this.componentElement;
if (contentElement) {
contentElement.innerHTML = html;
}
}
/**
* Cleanup
*/
destroy() {
this.cancelAll();
this.clearAll();
if (this.dragDropZone) {
this.dragDropZone.destroy();
}
}
}
export default ComponentFileUploader;

View File

@@ -0,0 +1,568 @@
/**
* ComponentPlayground
*
* Interactive development tool for LiveComponents.
*
* Features:
* - Component browser with search and filtering
* - Live component preview with auto-refresh
* - JSON state editor with validation
* - Action tester with parameter support
* - Performance metrics visualization
* - Template code generator
*
* Usage:
* import { ComponentPlayground } from './modules/livecomponent/ComponentPlayground';
*
* const playground = new ComponentPlayground('#playground-container');
* playground.init();
*/
export class ComponentPlayground {
/**
* @param {string} containerSelector - Container element selector
*/
constructor(containerSelector) {
this.container = document.querySelector(containerSelector);
if (!this.container) {
throw new Error(`Container not found: ${containerSelector}`);
}
// State
this.components = [];
this.selectedComponent = null;
this.componentMetadata = null;
this.currentState = {};
this.previewInstanceId = `playground-${Date.now()}`;
// UI Elements (will be created in init())
this.componentList = null;
this.componentSearch = null;
this.stateEditor = null;
this.previewContainer = null;
this.actionTester = null;
this.metricsDisplay = null;
// Performance tracking
this.metrics = {
renderTime: 0,
stateSize: 0,
actionExecutions: 0
};
}
/**
* Initialize playground
*/
async init() {
this.buildUI();
await this.loadComponents();
this.attachEventListeners();
}
/**
* Build playground UI structure
*/
buildUI() {
this.container.innerHTML = `
<div class="playground">
<!-- Header -->
<header class="playground__header">
<h1 class="playground__title">LiveComponent Playground</h1>
<p class="playground__subtitle">Interactive development tool for testing LiveComponents</p>
</header>
<!-- Main Layout -->
<div class="playground__layout">
<!-- Sidebar: Component Selector -->
<aside class="playground__sidebar">
<div class="playground__search">
<input
type="text"
id="component-search"
class="playground__search-input"
placeholder="Search components..."
autocomplete="off"
/>
</div>
<div id="component-list" class="playground__component-list">
<div class="playground__loading">Loading components...</div>
</div>
</aside>
<!-- Main Content -->
<main class="playground__main">
<!-- State Editor -->
<section class="playground__section">
<h2 class="playground__section-title">Component State</h2>
<div class="playground__state-editor">
<textarea
id="state-editor"
class="playground__textarea"
placeholder='{\n "property": "value"\n}'
rows="10"
></textarea>
<div class="playground__editor-actions">
<button id="apply-state" class="playground__button playground__button--primary">
Apply State
</button>
<button id="reset-state" class="playground__button">
Reset
</button>
<button id="format-json" class="playground__button">
Format JSON
</button>
</div>
<div id="state-validation" class="playground__validation"></div>
</div>
</section>
<!-- Live Preview -->
<section class="playground__section">
<h2 class="playground__section-title">Live Preview</h2>
<div id="preview-container" class="playground__preview">
<div class="playground__empty">
Select a component to preview
</div>
</div>
<div id="metrics-display" class="playground__metrics"></div>
</section>
<!-- Action Tester -->
<section class="playground__section">
<h2 class="playground__section-title">Actions</h2>
<div id="action-tester" class="playground__actions">
<div class="playground__empty">
Select a component to test actions
</div>
</div>
</section>
<!-- Code Generator -->
<section class="playground__section">
<h2 class="playground__section-title">Template Code</h2>
<div class="playground__code-generator">
<pre id="generated-code" class="playground__code"><code>Select a component to generate code</code></pre>
<button id="copy-code" class="playground__button">
Copy to Clipboard
</button>
</div>
</section>
</main>
</div>
</div>
`;
// Cache UI elements
this.componentList = this.container.querySelector('#component-list');
this.componentSearch = this.container.querySelector('#component-search');
this.stateEditor = this.container.querySelector('#state-editor');
this.previewContainer = this.container.querySelector('#preview-container');
this.actionTester = this.container.querySelector('#action-tester');
this.metricsDisplay = this.container.querySelector('#metrics-display');
}
/**
* Load all available components
*/
async loadComponents() {
try {
const response = await fetch('/playground/api/components');
const data = await response.json();
this.components = data.components || [];
this.renderComponentList(this.components);
} catch (error) {
this.componentList.innerHTML = `
<div class="playground__error">
Failed to load components: ${error.message}
</div>
`;
}
}
/**
* Render component list
*/
renderComponentList(components) {
if (components.length === 0) {
this.componentList.innerHTML = '<div class="playground__empty">No components found</div>';
return;
}
this.componentList.innerHTML = components.map(component => `
<div class="playground__component-item" data-component="${component.name}">
<div class="playground__component-name">${component.name}</div>
<div class="playground__component-meta">
<span class="playground__badge">${component.properties} props</span>
<span class="playground__badge">${component.actions} actions</span>
${component.has_cache ? '<span class="playground__badge playground__badge--cache">cached</span>' : ''}
</div>
</div>
`).join('');
}
/**
* Attach event listeners
*/
attachEventListeners() {
// Component selection
this.componentList.addEventListener('click', (e) => {
const item = e.target.closest('.playground__component-item');
if (item) {
this.selectComponent(item.dataset.component);
}
});
// Component search
this.componentSearch.addEventListener('input', (e) => {
this.filterComponents(e.target.value);
});
// State editor actions
this.container.querySelector('#apply-state').addEventListener('click', () => {
this.applyState();
});
this.container.querySelector('#reset-state').addEventListener('click', () => {
this.resetState();
});
this.container.querySelector('#format-json').addEventListener('click', () => {
this.formatJSON();
});
// Copy code
this.container.querySelector('#copy-code').addEventListener('click', () => {
this.copyCode();
});
}
/**
* Filter components by search term
*/
filterComponents(searchTerm) {
const term = searchTerm.toLowerCase().trim();
if (!term) {
this.renderComponentList(this.components);
return;
}
const filtered = this.components.filter(component =>
component.name.toLowerCase().includes(term) ||
component.class.toLowerCase().includes(term)
);
this.renderComponentList(filtered);
}
/**
* Select component
*/
async selectComponent(componentName) {
// Update active state
this.componentList.querySelectorAll('.playground__component-item').forEach(item => {
item.classList.remove('playground__component-item--active');
});
const selectedItem = this.componentList.querySelector(`[data-component="${componentName}"]`);
if (selectedItem) {
selectedItem.classList.add('playground__component-item--active');
}
this.selectedComponent = componentName;
// Load metadata
await this.loadComponentMetadata(componentName);
// Reset state
this.resetState();
// Render actions
this.renderActions();
// Generate code
this.updateGeneratedCode();
}
/**
* Load component metadata
*/
async loadComponentMetadata(componentName) {
try {
const response = await fetch(`/playground/api/component/${componentName}`);
const data = await response.json();
if (data.success) {
this.componentMetadata = data.data;
} else {
console.error('Failed to load metadata:', data.error);
}
} catch (error) {
console.error('Error loading metadata:', error);
}
}
/**
* Apply state from editor
*/
async applyState() {
const jsonText = this.stateEditor.value.trim();
const validationEl = this.container.querySelector('#state-validation');
// Validate JSON
try {
const state = jsonText ? JSON.parse(jsonText) : {};
this.currentState = state;
validationEl.innerHTML = '<span class="playground__success">✓ Valid JSON</span>';
// Preview component with new state
await this.previewComponent();
} catch (error) {
validationEl.innerHTML = `<span class="playground__error">✗ Invalid JSON: ${error.message}</span>`;
}
}
/**
* Reset state to default
*/
resetState() {
this.currentState = {};
this.stateEditor.value = '{}';
this.container.querySelector('#state-validation').innerHTML = '';
if (this.selectedComponent) {
this.previewComponent();
}
}
/**
* Format JSON in editor
*/
formatJSON() {
try {
const json = JSON.parse(this.stateEditor.value || '{}');
this.stateEditor.value = JSON.stringify(json, null, 2);
this.container.querySelector('#state-validation').innerHTML = '<span class="playground__success">✓ Formatted</span>';
} catch (error) {
this.container.querySelector('#state-validation').innerHTML = `<span class="playground__error">✗ Invalid JSON</span>`;
}
}
/**
* Preview component with current state
*/
async previewComponent() {
if (!this.selectedComponent) return;
this.previewContainer.innerHTML = '<div class="playground__loading">Loading preview...</div>';
try {
const startTime = performance.now();
const response = await fetch('/playground/api/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
component_name: this.selectedComponent,
state: this.currentState,
instance_id: this.previewInstanceId
})
});
const data = await response.json();
const endTime = performance.now();
if (data.success) {
this.previewContainer.innerHTML = data.html;
// Update metrics
this.metrics.renderTime = data.render_time_ms;
this.metrics.stateSize = JSON.stringify(data.state).length;
this.updateMetrics();
// Initialize LiveComponent in preview
if (window.LiveComponent) {
window.LiveComponent.initComponent(this.previewContainer.firstElementChild);
}
} else {
this.previewContainer.innerHTML = `
<div class="playground__error">
Preview failed: ${data.error}
</div>
`;
}
} catch (error) {
this.previewContainer.innerHTML = `
<div class="playground__error">
Error: ${error.message}
</div>
`;
}
}
/**
* Render actions for selected component
*/
renderActions() {
if (!this.componentMetadata || !this.componentMetadata.actions) {
this.actionTester.innerHTML = '<div class="playground__empty">No actions available</div>';
return;
}
const actions = this.componentMetadata.actions.filter(action =>
!['onMount', 'onUpdated', 'onDestroy'].includes(action.name)
);
if (actions.length === 0) {
this.actionTester.innerHTML = '<div class="playground__empty">No actions available</div>';
return;
}
this.actionTester.innerHTML = actions.map(action => `
<div class="playground__action">
<button
class="playground__button playground__button--action"
data-action="${action.name}"
>
${action.name}()
</button>
${action.parameters.length > 0 ? `
<div class="playground__action-params">
${action.parameters.map(param => `
<label>
${param.name} (${param.type}):
<input type="text" data-param="${param.name}" placeholder="${param.type}" />
</label>
`).join('')}
</div>
` : ''}
</div>
`).join('');
// Attach action button listeners
this.actionTester.querySelectorAll('[data-action]').forEach(button => {
button.addEventListener('click', () => {
this.executeAction(button.dataset.action, button.closest('.playground__action'));
});
});
}
/**
* Execute component action
*/
async executeAction(actionName, actionElement) {
// Get parameters
const parameters = {};
if (actionElement) {
actionElement.querySelectorAll('[data-param]').forEach(input => {
const paramName = input.dataset.param;
let value = input.value;
// Try to parse as number or boolean
if (value === 'true') value = true;
else if (value === 'false') value = false;
else if (!isNaN(value) && value !== '') value = Number(value);
parameters[paramName] = value;
});
}
try {
const response = await fetch('/playground/api/action', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
component_id: `${this.selectedComponent}:${this.previewInstanceId}`,
action_name: actionName,
parameters: parameters,
current_state: this.currentState
})
});
const data = await response.json();
if (data.success) {
// Update current state
this.currentState = data.new_state;
this.stateEditor.value = JSON.stringify(this.currentState, null, 2);
// Update preview
this.previewContainer.innerHTML = data.html;
// Update metrics
this.metrics.actionExecutions++;
this.updateMetrics();
// Re-initialize LiveComponent
if (window.LiveComponent) {
window.LiveComponent.initComponent(this.previewContainer.firstElementChild);
}
} else {
alert(`Action failed: ${data.error}`);
}
} catch (error) {
alert(`Error executing action: ${error.message}`);
}
}
/**
* Update metrics display
*/
updateMetrics() {
this.metricsDisplay.innerHTML = `
<div class="playground__metrics-grid">
<div class="playground__metric">
<span class="playground__metric-label">Render Time</span>
<span class="playground__metric-value">${this.metrics.renderTime.toFixed(2)}ms</span>
</div>
<div class="playground__metric">
<span class="playground__metric-label">State Size</span>
<span class="playground__metric-value">${this.metrics.stateSize} bytes</span>
</div>
<div class="playground__metric">
<span class="playground__metric-label">Actions Executed</span>
<span class="playground__metric-value">${this.metrics.actionExecutions}</span>
</div>
</div>
`;
}
/**
* Update generated template code
*/
updateGeneratedCode() {
if (!this.selectedComponent) return;
const code = `<!-- Use in your template -->\n{{{ ${this.selectedComponent} }}}`;
this.container.querySelector('#generated-code code').textContent = code;
}
/**
* Copy generated code to clipboard
*/
async copyCode() {
const code = this.container.querySelector('#generated-code code').textContent;
try {
await navigator.clipboard.writeText(code);
const button = this.container.querySelector('#copy-code');
const originalText = button.textContent;
button.textContent = '✓ Copied!';
setTimeout(() => {
button.textContent = originalText;
}, 2000);
} catch (error) {
alert('Failed to copy code to clipboard');
}
}
}
export default ComponentPlayground;

View File

@@ -0,0 +1,288 @@
/**
* Lightweight DOM Patcher for LiveComponent Fragments
*
* Efficiently patches specific DOM fragments without full re-render.
* Optimized for LiveComponent use case with minimal overhead.
*
* Features:
* - Smart element matching by tag and data-lc-fragment attribute
* - Attribute diffing and patching
* - Text content updates
* - Child node reconciliation
* - Preserves focus and scroll position where possible
*
* Philosophy:
* - Keep it simple and focused
* - No external dependencies
* - Framework-compliant modern JavaScript
*/
export class DomPatcher {
/**
* Patch a specific fragment within a container
*
* @param {HTMLElement} container - Container element to search in
* @param {string} fragmentName - Fragment name (data-lc-fragment value)
* @param {string} newHtml - New HTML for the fragment
* @returns {boolean} - True if fragment was patched, false if not found
*/
patchFragment(container, fragmentName, newHtml) {
// Find existing fragment element
const existingElement = container.querySelector(`[data-lc-fragment="${fragmentName}"]`);
if (!existingElement) {
console.warn(`[DomPatcher] Fragment not found: ${fragmentName}`);
return false;
}
// Parse new HTML into element
const temp = document.createElement('div');
temp.innerHTML = newHtml;
const newElement = temp.firstElementChild;
if (!newElement) {
console.warn(`[DomPatcher] Invalid HTML for fragment: ${fragmentName}`);
return false;
}
// Verify fragment name matches
if (newElement.getAttribute('data-lc-fragment') !== fragmentName) {
console.warn(`[DomPatcher] Fragment name mismatch: expected ${fragmentName}, got ${newElement.getAttribute('data-lc-fragment')}`);
return false;
}
// Patch the element in place
this.patchElement(existingElement, newElement);
return true;
}
/**
* Patch multiple fragments at once
*
* @param {HTMLElement} container - Container element
* @param {Object.<string, string>} fragments - Map of fragment names to HTML
* @returns {Object.<string, boolean>} - Map of fragment names to success status
*/
patchFragments(container, fragments) {
const results = {};
for (const [fragmentName, html] of Object.entries(fragments)) {
results[fragmentName] = this.patchFragment(container, fragmentName, html);
}
return results;
}
/**
* Patch an element with new content
*
* Core patching logic that efficiently updates only what changed.
*
* @param {HTMLElement} oldElement - Existing element to patch
* @param {HTMLElement} newElement - New element with updated content
*/
patchElement(oldElement, newElement) {
// 1. Patch attributes
this.patchAttributes(oldElement, newElement);
// 2. Patch child nodes
this.patchChildren(oldElement, newElement);
}
/**
* Patch element attributes
*
* Only updates attributes that actually changed.
*
* @param {HTMLElement} oldElement - Existing element
* @param {HTMLElement} newElement - New element
*/
patchAttributes(oldElement, newElement) {
// Get all attributes from both elements
const oldAttrs = new Map();
const newAttrs = new Map();
for (const attr of oldElement.attributes) {
oldAttrs.set(attr.name, attr.value);
}
for (const attr of newElement.attributes) {
newAttrs.set(attr.name, attr.value);
}
// Remove attributes that no longer exist
for (const [name, value] of oldAttrs) {
if (!newAttrs.has(name)) {
oldElement.removeAttribute(name);
}
}
// Add or update attributes
for (const [name, value] of newAttrs) {
if (oldAttrs.get(name) !== value) {
oldElement.setAttribute(name, value);
}
}
}
/**
* Patch child nodes
*
* Reconciles child nodes between old and new elements.
* Uses simple key-based matching for efficiency.
*
* @param {HTMLElement} oldElement - Existing element
* @param {HTMLElement} newElement - New element
*/
patchChildren(oldElement, newElement) {
const oldChildren = Array.from(oldElement.childNodes);
const newChildren = Array.from(newElement.childNodes);
const maxLength = Math.max(oldChildren.length, newChildren.length);
for (let i = 0; i < maxLength; i++) {
const oldChild = oldChildren[i];
const newChild = newChildren[i];
if (!oldChild && newChild) {
// New child added - append it
oldElement.appendChild(newChild.cloneNode(true));
} else if (oldChild && !newChild) {
// Child removed - remove it
oldElement.removeChild(oldChild);
} else if (oldChild && newChild) {
// Both exist - patch or replace
if (this.shouldPatch(oldChild, newChild)) {
if (oldChild.nodeType === Node.ELEMENT_NODE) {
this.patchElement(oldChild, newChild);
} else if (oldChild.nodeType === Node.TEXT_NODE) {
if (oldChild.nodeValue !== newChild.nodeValue) {
oldChild.nodeValue = newChild.nodeValue;
}
}
} else {
// Different node types or tags - replace
oldElement.replaceChild(newChild.cloneNode(true), oldChild);
}
}
}
}
/**
* Determine if two nodes should be patched or replaced
*
* Nodes should be patched if they are compatible (same type and tag).
*
* @param {Node} oldNode - Existing node
* @param {Node} newNode - New node
* @returns {boolean} - True if nodes should be patched
*/
shouldPatch(oldNode, newNode) {
// Different node types - replace
if (oldNode.nodeType !== newNode.nodeType) {
return false;
}
// Text nodes can always be patched
if (oldNode.nodeType === Node.TEXT_NODE) {
return true;
}
// Element nodes - check if same tag
if (oldNode.nodeType === Node.ELEMENT_NODE) {
if (oldNode.tagName !== newNode.tagName) {
return false;
}
// Check for special keys that indicate identity
const oldKey = oldNode.getAttribute('data-lc-key') || oldNode.getAttribute('id');
const newKey = newNode.getAttribute('data-lc-key') || newNode.getAttribute('id');
// If both have keys, they must match
if (oldKey && newKey) {
return oldKey === newKey;
}
// Otherwise, assume they match (same tag is enough)
return true;
}
// Other node types - replace
return false;
}
/**
* Preserve focus state before patching
*
* Returns a function to restore focus after patching.
*
* @param {HTMLElement} container - Container being patched
* @returns {Function} - Restore function
*/
preserveFocus(container) {
const activeElement = document.activeElement;
// Check if focused element is within container
if (!activeElement || !container.contains(activeElement)) {
return () => {}; // No-op restore
}
// Get selector for focused element
const selector = this.getElementSelector(activeElement);
const selectionStart = activeElement.selectionStart;
const selectionEnd = activeElement.selectionEnd;
// Return restore function
return () => {
try {
if (selector) {
const element = container.querySelector(selector);
if (element && element.focus) {
element.focus();
// Restore selection for input/textarea
if (element.setSelectionRange &&
typeof selectionStart === 'number' &&
typeof selectionEnd === 'number') {
element.setSelectionRange(selectionStart, selectionEnd);
}
}
}
} catch (e) {
// Focus restoration failed - not critical
console.debug('[DomPatcher] Could not restore focus:', e);
}
};
}
/**
* Get a selector for an element
*
* Tries to create a unique selector using ID, name, or data attributes.
*
* @param {HTMLElement} element - Element to get selector for
* @returns {string|null} - CSS selector or null
*/
getElementSelector(element) {
if (element.id) {
return `#${element.id}`;
}
if (element.name) {
return `[name="${element.name}"]`;
}
const lcKey = element.getAttribute('data-lc-key');
if (lcKey) {
return `[data-lc-key="${lcKey}"]`;
}
// Fallback - not guaranteed to be unique
return element.tagName.toLowerCase();
}
}
// Create singleton instance
export const domPatcher = new DomPatcher();
export default domPatcher;

View File

@@ -0,0 +1,419 @@
/**
* FileUploadWidget - Pre-built UI Component for File Uploads in LiveComponents
*
* Provides a complete upload interface with:
* - Drag & Drop zone
* - File list with thumbnails
* - Progress bars
* - File validation feedback
* - Remove/cancel capabilities
*
* @package Framework\LiveComponents
*/
import { ComponentFileUploader } from './ComponentFileUploader.js';
/**
* FileUploadWidget - Ready-to-use Upload UI
*/
export class FileUploadWidget {
constructor(containerElement, options = {}) {
this.container = containerElement;
this.componentElement = containerElement.closest('[data-live-id]');
if (!this.componentElement) {
throw new Error('FileUploadWidget must be used inside a LiveComponent');
}
// Widget options
this.options = {
maxFileSize: options.maxFileSize || 10 * 1024 * 1024,
allowedMimeTypes: options.allowedMimeTypes || [],
allowedExtensions: options.allowedExtensions || [],
maxFiles: options.maxFiles || 10,
showPreviews: options.showPreviews !== false,
showProgress: options.showProgress !== false,
showFileList: options.showFileList !== false,
autoUpload: options.autoUpload !== false,
multiple: options.multiple !== false,
dropZoneText: options.dropZoneText || 'Drag & drop files here or click to browse',
browseButtonText: options.browseButtonText || 'Browse Files',
uploadButtonText: options.uploadButtonText || 'Upload All',
...options
};
this.files = new Map(); // Map<fileId, FileUIElement>
this.buildUI();
this.initializeUploader();
}
/**
* Build the widget UI
*/
buildUI() {
// Create widget structure
this.container.innerHTML = `
<div class="file-upload-widget">
<!-- Drop Zone -->
<div class="file-upload-dropzone" data-dropzone>
<div class="dropzone-content">
<svg class="dropzone-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<p class="dropzone-text">${this.options.dropZoneText}</p>
<button type="button" class="btn btn-primary dropzone-button" data-browse-button>
${this.options.browseButtonText}
</button>
<input
type="file"
class="dropzone-input"
data-file-input
${this.options.multiple ? 'multiple' : ''}
${this.options.allowedMimeTypes.length > 0 ? `accept="${this.options.allowedMimeTypes.join(',')}"` : ''}
style="display: none;"
/>
</div>
</div>
<!-- File List -->
${this.options.showFileList ? `
<div class="file-upload-list" data-file-list style="display: none;">
<div class="file-list-header">
<h4>Files (0)</h4>
<div class="file-list-actions">
${!this.options.autoUpload ? `<button type="button" class="btn btn-primary btn-sm" data-upload-all>${this.options.uploadButtonText}</button>` : ''}
<button type="button" class="btn btn-secondary btn-sm" data-clear-all>Clear All</button>
</div>
</div>
<div class="file-list-items" data-file-items></div>
</div>
` : ''}
<!-- Overall Progress (shown when uploading) -->
${this.options.showProgress ? `
<div class="file-upload-progress" data-overall-progress style="display: none;">
<div class="progress-info">
<span class="progress-label">Uploading files...</span>
<span class="progress-percentage" data-progress-text>0%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" data-progress-fill style="width: 0%"></div>
</div>
</div>
` : ''}
</div>
`;
// Cache DOM elements
this.dropZone = this.container.querySelector('[data-dropzone]');
this.fileInput = this.container.querySelector('[data-file-input]');
this.browseButton = this.container.querySelector('[data-browse-button]');
this.fileList = this.container.querySelector('[data-file-list]');
this.fileItems = this.container.querySelector('[data-file-items]');
this.overallProgress = this.container.querySelector('[data-overall-progress]');
this.progressFill = this.container.querySelector('[data-progress-fill]');
this.progressText = this.container.querySelector('[data-progress-text]');
this.uploadAllButton = this.container.querySelector('[data-upload-all]');
this.clearAllButton = this.container.querySelector('[data-clear-all]');
// Bind UI events
this.browseButton?.addEventListener('click', () => this.fileInput.click());
this.uploadAllButton?.addEventListener('click', () => this.uploader.uploadAll());
this.clearAllButton?.addEventListener('click', () => this.clearAll());
}
/**
* Initialize the uploader
*/
initializeUploader() {
this.uploader = new ComponentFileUploader(this.componentElement, {
...this.options,
dropZone: this.dropZone,
fileInput: this.fileInput,
onFileAdded: (data) => this.handleFileAdded(data),
onFileRemoved: (data) => this.handleFileRemoved(data),
onUploadStart: (data) => this.handleUploadStart(data),
onUploadProgress: (data) => this.handleUploadProgress(data),
onUploadComplete: (data) => this.handleUploadComplete(data),
onUploadError: (data) => this.handleUploadError(data),
onAllUploadsComplete: (data) => this.handleAllUploadsComplete(data)
});
}
/**
* Handle file added
*/
handleFileAdded({ fileId, file, progress }) {
if (!this.options.showFileList) return;
// Show file list
if (this.fileList) {
this.fileList.style.display = 'block';
}
// Create file UI element
const fileElement = this.createFileElement(fileId, file, progress);
this.fileItems.appendChild(fileElement);
this.files.set(fileId, fileElement);
// Update file count
this.updateFileCount();
}
/**
* Handle file removed
*/
handleFileRemoved({ fileId }) {
const fileElement = this.files.get(fileId);
if (fileElement) {
fileElement.remove();
this.files.delete(fileId);
}
// Update file count
this.updateFileCount();
// Hide file list if empty
if (this.files.size === 0 && this.fileList) {
this.fileList.style.display = 'none';
}
}
/**
* Handle upload start
*/
handleUploadStart({ fileId }) {
this.updateFileStatus(fileId, 'uploading');
// Show overall progress
if (this.overallProgress) {
this.overallProgress.style.display = 'block';
}
}
/**
* Handle upload progress
*/
handleUploadProgress({ fileId, percentage, uploadedBytes, totalBytes, uploadSpeed, remainingTime }) {
// Update file progress bar
const fileElement = this.files.get(fileId);
if (fileElement) {
const progressBar = fileElement.querySelector('.file-progress-fill');
const progressText = fileElement.querySelector('.file-progress-text');
if (progressBar) {
progressBar.style.width = `${percentage}%`;
}
if (progressText) {
progressText.textContent = `${percentage}%`;
}
}
// Update overall progress
const stats = this.uploader.getStats();
if (this.progressFill) {
this.progressFill.style.width = `${stats.overallProgress}%`;
}
if (this.progressText) {
this.progressText.textContent = `${stats.overallProgress}%`;
}
}
/**
* Handle upload complete
*/
handleUploadComplete({ fileId, response }) {
this.updateFileStatus(fileId, 'complete');
}
/**
* Handle upload error
*/
handleUploadError({ fileId, error }) {
this.updateFileStatus(fileId, 'error', error);
}
/**
* Handle all uploads complete
*/
handleAllUploadsComplete({ totalFiles, successCount, errorCount }) {
// Hide overall progress after a delay
setTimeout(() => {
if (this.overallProgress) {
this.overallProgress.style.display = 'none';
}
}, 2000);
}
/**
* Create file UI element
*/
createFileElement(fileId, file, progress) {
const div = document.createElement('div');
div.className = 'file-item';
div.dataset.fileId = fileId;
const statusClass = progress.error ? 'error' : 'pending';
div.innerHTML = `
<div class="file-preview">
${this.options.showPreviews && file.type.startsWith('image/') ?
`<img class="file-thumbnail" src="${URL.createObjectURL(file)}" alt="${file.name}" />` :
this.getFileIconSvg(file.type)
}
</div>
<div class="file-info">
<div class="file-name" title="${file.name}">${this.truncateFileName(file.name, 40)}</div>
<div class="file-meta">
<span class="file-size">${this.formatBytes(file.size)}</span>
<span class="file-status" data-status="${statusClass}">${progress.error || 'Pending'}</span>
</div>
${this.options.showProgress ? `
<div class="file-progress" style="display: none;">
<div class="file-progress-bar">
<div class="file-progress-fill" style="width: 0%"></div>
</div>
<span class="file-progress-text">0%</span>
</div>
` : ''}
${progress.error ? `
<div class="file-error">${progress.error}</div>
` : ''}
</div>
<div class="file-actions">
<button type="button" class="btn-icon" data-remove-file title="Remove">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
`;
// Bind remove button
const removeButton = div.querySelector('[data-remove-file]');
removeButton.addEventListener('click', () => this.uploader.removeFile(fileId));
return div;
}
/**
* Update file status in UI
*/
updateFileStatus(fileId, status, errorMessage = null) {
const fileElement = this.files.get(fileId);
if (!fileElement) return;
const statusElement = fileElement.querySelector('.file-status');
const progressElement = fileElement.querySelector('.file-progress');
const errorElement = fileElement.querySelector('.file-error');
// Update status text and class
if (statusElement) {
statusElement.dataset.status = status;
const statusText = {
pending: 'Pending',
uploading: 'Uploading...',
complete: 'Complete',
error: 'Error'
}[status] || status;
statusElement.textContent = statusText;
}
// Show/hide progress
if (progressElement) {
progressElement.style.display = status === 'uploading' ? 'flex' : 'none';
}
// Handle errors
if (status === 'error' && errorMessage) {
if (errorElement) {
errorElement.textContent = errorMessage;
errorElement.style.display = 'block';
}
}
// Add completion/error visual feedback
fileElement.classList.remove('file-uploading', 'file-complete', 'file-error');
if (status === 'uploading') fileElement.classList.add('file-uploading');
if (status === 'complete') fileElement.classList.add('file-complete');
if (status === 'error') fileElement.classList.add('file-error');
}
/**
* Update file count in header
*/
updateFileCount() {
const header = this.container.querySelector('.file-list-header h4');
if (header) {
header.textContent = `Files (${this.files.size})`;
}
}
/**
* Clear all files
*/
clearAll() {
this.uploader.clearAll();
this.fileItems.innerHTML = '';
this.files.clear();
if (this.fileList) {
this.fileList.style.display = 'none';
}
this.updateFileCount();
}
/**
* Helper: Format bytes
*/
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
/**
* Helper: Truncate file name
*/
truncateFileName(name, maxLength) {
if (name.length <= maxLength) return name;
const extension = name.split('.').pop();
const baseName = name.substring(0, name.length - extension.length - 1);
const truncated = baseName.substring(0, maxLength - extension.length - 4);
return `${truncated}...${extension}`;
}
/**
* Helper: Get file icon SVG
*/
getFileIconSvg(mimeType) {
// Document icon
return `
<svg class="file-icon" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
`;
}
/**
* Cleanup
*/
destroy() {
this.uploader.destroy();
this.container.innerHTML = '';
this.files.clear();
}
}
export default FileUploadWidget;

View File

@@ -0,0 +1,415 @@
/**
* Lazy Component Loader
*
* Loads LiveComponents only when they enter the viewport using Intersection Observer API.
* Provides performance optimization for pages with many components.
*
* Features:
* - Viewport-based loading with configurable thresholds
* - Placeholder management during loading
* - Progressive loading with priorities
* - Memory-efficient intersection tracking
* - Automatic cleanup and disconnection
*
* Usage:
* <div data-live-component-lazy="notification-center:user-123"
* data-lazy-threshold="0.1"
* data-lazy-priority="high"
* data-lazy-placeholder="Loading notifications...">
* </div>
*/
export class LazyComponentLoader {
constructor(liveComponentManager) {
this.liveComponentManager = liveComponentManager;
this.observer = null;
this.lazyComponents = new Map(); // element → config
this.loadingQueue = []; // Priority-based loading queue
this.isProcessingQueue = false;
this.defaultOptions = {
rootMargin: '50px', // Load 50px before entering viewport
threshold: 0.1 // Trigger when 10% visible
};
}
/**
* Initialize lazy loading system
*/
init() {
// Create Intersection Observer
this.observer = new IntersectionObserver(
(entries) => this.handleIntersection(entries),
this.defaultOptions
);
// Scan for lazy components
this.scanLazyComponents();
console.log(`[LazyLoader] Initialized with ${this.lazyComponents.size} lazy components`);
}
/**
* Scan DOM for lazy components
*/
scanLazyComponents() {
const lazyElements = document.querySelectorAll('[data-live-component-lazy]');
lazyElements.forEach(element => {
this.registerLazyComponent(element);
});
}
/**
* Register a lazy component for loading
*
* @param {HTMLElement} element - Component container
*/
registerLazyComponent(element) {
const componentId = element.dataset.liveComponentLazy;
if (!componentId) {
console.warn('[LazyLoader] Lazy component missing componentId:', element);
return;
}
// Extract configuration
const config = {
element,
componentId,
threshold: parseFloat(element.dataset.lazyThreshold) || this.defaultOptions.threshold,
priority: element.dataset.lazyPriority || 'normal', // high, normal, low
placeholder: element.dataset.lazyPlaceholder || null,
loaded: false,
loading: false
};
// Show placeholder
if (config.placeholder) {
this.showPlaceholder(element, config.placeholder);
}
// Store config
this.lazyComponents.set(element, config);
// Start observing
this.observer.observe(element);
console.log(`[LazyLoader] Registered lazy component: ${componentId}`);
}
/**
* Handle intersection observer callback
*
* @param {Array<IntersectionObserverEntry>} entries - Intersection entries
*/
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
const config = this.lazyComponents.get(entry.target);
if (config && !config.loaded && !config.loading) {
this.queueComponentLoad(config);
}
}
});
}
/**
* Queue component for loading with priority
*
* @param {Object} config - Component config
*/
queueComponentLoad(config) {
const priorityWeight = this.getPriorityWeight(config.priority);
this.loadingQueue.push({
config,
priority: priorityWeight,
timestamp: Date.now()
});
// Sort by priority (high to low) and then by timestamp (early to late)
this.loadingQueue.sort((a, b) => {
if (b.priority !== a.priority) {
return b.priority - a.priority;
}
return a.timestamp - b.timestamp;
});
console.log(`[LazyLoader] Queued: ${config.componentId} (priority: ${config.priority})`);
// Process queue
this.processLoadingQueue();
}
/**
* Get numeric weight for priority
*
* @param {string} priority - Priority level (high, normal, low)
* @returns {number} Priority weight
*/
getPriorityWeight(priority) {
const weights = {
'high': 3,
'normal': 2,
'low': 1
};
return weights[priority] || 2;
}
/**
* Process loading queue
*
* Loads components sequentially to avoid overloading server.
*/
async processLoadingQueue() {
if (this.isProcessingQueue || this.loadingQueue.length === 0) {
return;
}
this.isProcessingQueue = true;
while (this.loadingQueue.length > 0) {
const { config } = this.loadingQueue.shift();
if (!config.loaded && !config.loading) {
await this.loadComponent(config);
}
}
this.isProcessingQueue = false;
}
/**
* Load component from server
*
* @param {Object} config - Component config
*/
async loadComponent(config) {
config.loading = true;
try {
console.log(`[LazyLoader] Loading: ${config.componentId}`);
// Show loading indicator
this.showLoadingIndicator(config.element);
// Request component HTML from server
const response = await fetch(`/live-component/${config.componentId}/lazy-load`, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load component');
}
// Replace placeholder with component HTML
config.element.innerHTML = data.html;
// Mark element as regular LiveComponent (no longer lazy)
config.element.setAttribute('data-live-component', config.componentId);
config.element.removeAttribute('data-live-component-lazy');
// Copy CSRF token to element
if (data.csrf_token) {
config.element.dataset.csrfToken = data.csrf_token;
}
// Copy state to element
if (data.state) {
config.element.dataset.state = JSON.stringify(data.state);
}
// Initialize as regular LiveComponent
this.liveComponentManager.init(config.element);
// Mark as loaded
config.loaded = true;
config.loading = false;
// Stop observing
this.observer.unobserve(config.element);
console.log(`[LazyLoader] Loaded: ${config.componentId}`);
// Dispatch custom event
config.element.dispatchEvent(new CustomEvent('livecomponent:lazy:loaded', {
detail: { componentId: config.componentId }
}));
} catch (error) {
console.error(`[LazyLoader] Failed to load ${config.componentId}:`, error);
config.loading = false;
// Show error state
this.showError(config.element, error.message);
// Dispatch error event
config.element.dispatchEvent(new CustomEvent('livecomponent:lazy:error', {
detail: {
componentId: config.componentId,
error: error.message
}
}));
}
}
/**
* Show placeholder
*
* @param {HTMLElement} element - Container element
* @param {string} text - Placeholder text
*/
showPlaceholder(element, text) {
element.innerHTML = `
<div class="livecomponent-lazy-placeholder" style="
padding: 2rem;
text-align: center;
color: #666;
background: #f5f5f5;
border-radius: 8px;
border: 1px dashed #ddd;
">
<div style="font-size: 1.5rem; margin-bottom: 0.5rem;">⏳</div>
<div>${text}</div>
</div>
`;
}
/**
* Show loading indicator
*
* @param {HTMLElement} element - Container element
*/
showLoadingIndicator(element) {
element.innerHTML = `
<div class="livecomponent-lazy-loading" style="
padding: 2rem;
text-align: center;
color: #2196F3;
background: #f5f5f5;
border-radius: 8px;
border: 1px solid #e3f2fd;
">
<div class="spinner" style="
width: 40px;
height: 40px;
margin: 0 auto 1rem;
border: 4px solid #e3f2fd;
border-top: 4px solid #2196F3;
border-radius: 50%;
animation: spin 1s linear infinite;
"></div>
<div>Loading component...</div>
</div>
<style>
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
`;
}
/**
* Show error state
*
* @param {HTMLElement} element - Container element
* @param {string} errorMessage - Error message
*/
showError(element, errorMessage) {
element.innerHTML = `
<div class="livecomponent-lazy-error" style="
padding: 2rem;
text-align: center;
color: #d32f2f;
background: #ffebee;
border-radius: 8px;
border: 1px solid #ef9a9a;
">
<div style="font-size: 1.5rem; margin-bottom: 0.5rem;">❌</div>
<div><strong>Failed to load component</strong></div>
<div style="margin-top: 0.5rem; font-size: 0.9rem; color: #c62828;">
${errorMessage}
</div>
</div>
`;
}
/**
* Unregister lazy component and stop observing
*
* @param {HTMLElement} element - Component element
*/
unregister(element) {
const config = this.lazyComponents.get(element);
if (!config) return;
// Stop observing
this.observer.unobserve(element);
// Remove from registry
this.lazyComponents.delete(element);
console.log(`[LazyLoader] Unregistered: ${config.componentId}`);
}
/**
* Destroy lazy loader
*
* Clean up all observers and references.
*/
destroy() {
// Disconnect observer
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
// Clear queue
this.loadingQueue = [];
// Clear registry
this.lazyComponents.clear();
console.log('[LazyLoader] Destroyed');
}
/**
* Get lazy loading statistics
*
* @returns {Object} Statistics
*/
getStats() {
let loaded = 0;
let loading = 0;
let pending = 0;
this.lazyComponents.forEach(config => {
if (config.loaded) loaded++;
else if (config.loading) loading++;
else pending++;
});
return {
total: this.lazyComponents.size,
loaded,
loading,
pending,
queued: this.loadingQueue.length
};
}
}
// Export for use in LiveComponent module
export default LazyComponentLoader;

View File

@@ -0,0 +1,429 @@
/**
* Nested Component Handler
*
* Manages parent-child relationships for nested LiveComponents on the client-side.
* Coordinates event bubbling, state synchronization, and lifecycle management.
*
* Features:
* - Parent-child relationship tracking
* - Event bubbling from child to parent
* - Child lifecycle coordination
* - State synchronization between parent and children
* - Automatic cleanup on component destruction
*
* Architecture:
* - Scans DOM for nested components (data-parent-component attribute)
* - Registers hierarchies with LiveComponentManager
* - Intercepts events to enable bubbling
* - Coordinates updates between parents and children
*/
export class NestedComponentHandler {
constructor(liveComponentManager) {
this.liveComponentManager = liveComponentManager;
// Registry: componentId → { parentId, childIds, depth }
this.hierarchyRegistry = new Map();
// Parent → [Children] mapping
this.childrenRegistry = new Map();
// Event bubbling callbacks: componentId → [callbacks]
this.bubbleCallbacks = new Map();
}
/**
* Initialize nested component system
* Scans DOM for nested components and registers hierarchies
*/
init() {
this.scanNestedComponents();
console.log(`[NestedComponents] Initialized with ${this.hierarchyRegistry.size} components`);
}
/**
* Scan DOM for nested components
* Looks for data-parent-component attribute to establish parent-child relationships
*/
scanNestedComponents() {
// Find all components with parent
const nestedComponents = document.querySelectorAll('[data-parent-component]');
nestedComponents.forEach(element => {
const componentId = element.dataset.liveComponent;
const parentId = element.dataset.parentComponent;
const depth = parseInt(element.dataset.nestingDepth) || 1;
if (componentId && parentId) {
this.registerHierarchy(componentId, parentId, depth);
}
});
// Register root components (no parent)
const rootComponents = document.querySelectorAll('[data-live-component]:not([data-parent-component])');
rootComponents.forEach(element => {
const componentId = element.dataset.liveComponent;
if (componentId && !this.hierarchyRegistry.has(componentId)) {
this.registerRoot(componentId);
}
});
}
/**
* Register root component (no parent)
*
* @param {string} componentId - Component ID
*/
registerRoot(componentId) {
this.hierarchyRegistry.set(componentId, {
parentId: null,
childIds: [],
depth: 0,
path: [componentId]
});
console.log(`[NestedComponents] Registered root: ${componentId}`);
}
/**
* Register component hierarchy
*
* @param {string} componentId - Child component ID
* @param {string} parentId - Parent component ID
* @param {number} depth - Nesting depth
*/
registerHierarchy(componentId, parentId, depth = 1) {
// Get parent's path
const parentHierarchy = this.hierarchyRegistry.get(parentId);
const parentPath = parentHierarchy ? parentHierarchy.path : [parentId];
// Create hierarchy entry
this.hierarchyRegistry.set(componentId, {
parentId,
childIds: [],
depth,
path: [...parentPath, componentId]
});
// Add to parent's children
if (!this.childrenRegistry.has(parentId)) {
this.childrenRegistry.set(parentId, []);
}
const children = this.childrenRegistry.get(parentId);
if (!children.includes(componentId)) {
children.push(componentId);
}
console.log(`[NestedComponents] Registered child: ${componentId} (parent: ${parentId}, depth: ${depth})`);
}
/**
* Register dynamic nested component at runtime
*
* @param {string} componentId - Component ID
* @param {string} parentId - Parent component ID
*/
registerDynamicChild(componentId, parentId) {
const parentHierarchy = this.hierarchyRegistry.get(parentId);
if (!parentHierarchy) {
console.warn(`[NestedComponents] Cannot register child - parent not found: ${parentId}`);
return;
}
const depth = parentHierarchy.depth + 1;
this.registerHierarchy(componentId, parentId, depth);
}
/**
* Get component hierarchy
*
* @param {string} componentId - Component ID
* @returns {Object|null} Hierarchy object or null
*/
getHierarchy(componentId) {
return this.hierarchyRegistry.get(componentId) || null;
}
/**
* Get parent component ID
*
* @param {string} componentId - Component ID
* @returns {string|null} Parent ID or null if root
*/
getParentId(componentId) {
const hierarchy = this.getHierarchy(componentId);
return hierarchy ? hierarchy.parentId : null;
}
/**
* Get child component IDs
*
* @param {string} componentId - Parent component ID
* @returns {Array<string>} Array of child IDs
*/
getChildIds(componentId) {
return this.childrenRegistry.get(componentId) || [];
}
/**
* Check if component has children
*
* @param {string} componentId - Component ID
* @returns {boolean} True if has children
*/
hasChildren(componentId) {
const children = this.getChildIds(componentId);
return children.length > 0;
}
/**
* Check if component is root
*
* @param {string} componentId - Component ID
* @returns {boolean} True if root component
*/
isRoot(componentId) {
const hierarchy = this.getHierarchy(componentId);
return hierarchy ? hierarchy.parentId === null : true;
}
/**
* Get nesting depth
*
* @param {string} componentId - Component ID
* @returns {number} Nesting depth (0 for root)
*/
getDepth(componentId) {
const hierarchy = this.getHierarchy(componentId);
return hierarchy ? hierarchy.depth : 0;
}
/**
* Get all ancestors (parent, grandparent, etc.)
*
* @param {string} componentId - Component ID
* @returns {Array<string>} Array of ancestor IDs (parent first, root last)
*/
getAncestors(componentId) {
const hierarchy = this.getHierarchy(componentId);
if (!hierarchy || !hierarchy.path) {
return [];
}
// Path includes current component, remove it
const ancestors = [...hierarchy.path];
ancestors.pop();
// Return in reverse order (parent first, root last)
return ancestors.reverse();
}
/**
* Bubble event up through component hierarchy
*
* Dispatches custom event to each ancestor until stopped or root reached.
*
* @param {string} sourceId - Component that dispatched the event
* @param {string} eventName - Event name
* @param {Object} payload - Event payload
* @returns {boolean} True if bubbled to root, false if stopped
*/
bubbleEvent(sourceId, eventName, payload) {
console.log(`[NestedComponents] Bubbling event: ${eventName} from ${sourceId}`, payload);
let currentId = sourceId;
let bubbled = 0;
while (true) {
const parentId = this.getParentId(currentId);
// Reached root
if (parentId === null) {
console.log(`[NestedComponents] Event bubbled to root (${bubbled} levels)`);
return true;
}
// Get parent element
const parentElement = document.querySelector(`[data-live-component="${parentId}"]`);
if (!parentElement) {
console.warn(`[NestedComponents] Parent element not found: ${parentId}`);
return false;
}
// Dispatch custom event to parent
const bubbleEvent = new CustomEvent(`livecomponent:child:${eventName}`, {
detail: {
sourceId,
eventName,
payload,
currentLevel: bubbled
},
bubbles: false, // We handle bubbling manually
cancelable: true
});
const dispatched = parentElement.dispatchEvent(bubbleEvent);
// Event was cancelled - stop bubbling
if (!dispatched) {
console.log(`[NestedComponents] Event bubbling stopped at ${parentId}`);
return false;
}
// Check for registered callbacks
const callbacks = this.bubbleCallbacks.get(parentId);
if (callbacks) {
for (const callback of callbacks) {
const shouldContinue = callback(sourceId, eventName, payload);
if (shouldContinue === false) {
console.log(`[NestedComponents] Event bubbling stopped by callback at ${parentId}`);
return false;
}
}
}
// Move to next level
currentId = parentId;
bubbled++;
}
}
/**
* Register callback for child events
*
* @param {string} parentId - Parent component ID
* @param {Function} callback - Callback function (sourceId, eventName, payload) => boolean
*/
onChildEvent(parentId, callback) {
if (!this.bubbleCallbacks.has(parentId)) {
this.bubbleCallbacks.set(parentId, []);
}
this.bubbleCallbacks.get(parentId).push(callback);
console.log(`[NestedComponents] Registered child event callback for ${parentId}`);
}
/**
* Sync state from parent to children
*
* Useful for broadcasting shared state to all children.
*
* @param {string} parentId - Parent component ID
* @param {Object} sharedState - State to share with children
*/
syncStateToChildren(parentId, sharedState) {
const childIds = this.getChildIds(parentId);
console.log(`[NestedComponents] Syncing state to ${childIds.length} children of ${parentId}`);
childIds.forEach(childId => {
const childElement = document.querySelector(`[data-live-component="${childId}"]`);
if (!childElement) return;
// Dispatch state sync event
childElement.dispatchEvent(new CustomEvent('livecomponent:parent:state-sync', {
detail: { parentId, sharedState }
}));
// Update child state if applicable
// Child components can listen to this event and update accordingly
});
}
/**
* Update all children when parent changes
*
* @param {string} parentId - Parent component ID
* @param {Object} updates - Updates to apply to children
*/
async updateChildren(parentId, updates) {
const childIds = this.getChildIds(parentId);
console.log(`[NestedComponents] Updating ${childIds.length} children of ${parentId}`);
// Update children in parallel for performance
const updatePromises = childIds.map(async childId => {
const childElement = document.querySelector(`[data-live-component="${childId}"]`);
if (!childElement) return;
// Trigger child component update via LiveComponent action
// This depends on how your components handle updates
// For now, just dispatch an event
childElement.dispatchEvent(new CustomEvent('livecomponent:parent:update', {
detail: { parentId, updates }
}));
});
await Promise.all(updatePromises);
}
/**
* Unregister component and cleanup
*
* @param {string} componentId - Component to unregister
*/
unregister(componentId) {
// Remove from hierarchy registry
const hierarchy = this.hierarchyRegistry.get(componentId);
this.hierarchyRegistry.delete(componentId);
// Remove from parent's children
if (hierarchy && hierarchy.parentId) {
const siblings = this.childrenRegistry.get(hierarchy.parentId);
if (siblings) {
const index = siblings.indexOf(componentId);
if (index !== -1) {
siblings.splice(index, 1);
}
}
}
// Remove children registry
this.childrenRegistry.delete(componentId);
// Remove bubble callbacks
this.bubbleCallbacks.delete(componentId);
console.log(`[NestedComponents] Unregistered: ${componentId}`);
}
/**
* Get hierarchy statistics
*
* @returns {Object} Statistics
*/
getStats() {
let rootCount = 0;
let maxDepth = 0;
this.hierarchyRegistry.forEach(hierarchy => {
if (hierarchy.parentId === null) {
rootCount++;
}
maxDepth = Math.max(maxDepth, hierarchy.depth);
});
return {
total_components: this.hierarchyRegistry.size,
root_components: rootCount,
child_components: this.hierarchyRegistry.size - rootCount,
max_nesting_depth: maxDepth,
parents_with_children: this.childrenRegistry.size
};
}
/**
* Destroy nested component handler
*/
destroy() {
this.hierarchyRegistry.clear();
this.childrenRegistry.clear();
this.bubbleCallbacks.clear();
console.log('[NestedComponents] Destroyed');
}
}
export default NestedComponentHandler;

View File

@@ -0,0 +1,445 @@
/**
* Optimistic State Manager
*
* Manages optimistic UI updates with version-based conflict resolution.
* Implements the "Optimistic UI" pattern where client immediately updates
* the UI before server confirms, then rolls back on conflicts.
*
* Key Features:
* - Immediate UI feedback (no loading spinners)
* - Version-based optimistic concurrency control
* - Automatic conflict detection and rollback
* - Pending operations queue with retry
* - User-friendly conflict resolution
*
* Protocol:
* 1. Client updates UI immediately (optimistic)
* 2. Client sends update to server with version
* 3. Server checks version and either:
* a) Accepts: version matches, returns new state with version++
* b) Rejects: version conflict, returns current server state
* 4. Client resolves:
* a) On accept: commit optimistic state
* b) On conflict: rollback to server state, show conflict UI
*/
export class OptimisticStateManager {
constructor() {
/**
* Pending operations queue
* Map<componentId, PendingOperation[]>
*/
this.pendingOperations = new Map();
/**
* Rollback snapshots
* Map<componentId, StateSnapshot>
*/
this.snapshots = new Map();
/**
* Conflict callbacks
* Map<componentId, ConflictCallback>
*/
this.conflictHandlers = new Map();
/**
* Optimistic operation counter
*/
this.operationIdCounter = 0;
}
/**
* Apply optimistic update
*
* Updates component state immediately without waiting for server confirmation.
* Creates snapshot for potential rollback.
*
* @param {string} componentId - Component identifier
* @param {Object} currentState - Current component state (with version)
* @param {Function} optimisticUpdate - Function that returns optimistically updated state
* @param {Object} metadata - Operation metadata (action, params)
* @returns {Object} Optimistic state with incremented version
*/
applyOptimisticUpdate(componentId, currentState, optimisticUpdate, metadata = {}) {
// Create snapshot before applying optimistic update
if (!this.snapshots.has(componentId)) {
this.createSnapshot(componentId, currentState);
}
// Extract version from state
const currentVersion = currentState.version || 1;
// Apply optimistic update
const optimisticState = optimisticUpdate(currentState);
// Increment version optimistically
const newState = {
...optimisticState,
version: currentVersion + 1
};
// Create pending operation
const operation = {
id: this.generateOperationId(),
componentId,
metadata,
expectedVersion: currentVersion,
optimisticState: newState,
timestamp: Date.now(),
status: 'pending' // pending, confirmed, failed
};
// Add to pending operations queue
this.addPendingOperation(componentId, operation);
console.log(`[OptimisticUI] Applied optimistic update for ${componentId}`, {
version: `${currentVersion}${currentVersion + 1}`,
operationId: operation.id,
pendingCount: this.getPendingOperationsCount(componentId)
});
return newState;
}
/**
* Confirm operation success
*
* Called when server successfully processes the operation.
* Commits the optimistic state and clears snapshot if no more pending operations.
*
* @param {string} componentId - Component identifier
* @param {string} operationId - Operation ID to confirm
* @param {Object} serverState - Server-confirmed state (may differ from optimistic)
* @returns {Object} Final state (server state or optimistic if versions match)
*/
confirmOperation(componentId, operationId, serverState) {
const operation = this.getPendingOperation(componentId, operationId);
if (!operation) {
console.warn(`[OptimisticUI] Cannot confirm unknown operation: ${operationId}`);
return serverState;
}
// Mark operation as confirmed
operation.status = 'confirmed';
// Remove from pending queue
this.removePendingOperation(componentId, operationId);
console.log(`[OptimisticUI] Confirmed operation ${operationId} for ${componentId}`, {
pendingCount: this.getPendingOperationsCount(componentId)
});
// If no more pending operations, clear snapshot
if (!this.hasPendingOperations(componentId)) {
this.clearSnapshot(componentId);
}
return serverState;
}
/**
* Handle version conflict
*
* Called when server rejects operation due to version mismatch.
* Rolls back to snapshot and applies server state.
*
* @param {string} componentId - Component identifier
* @param {string} operationId - Failed operation ID
* @param {Object} serverState - Current server state
* @param {Object} conflict - Conflict information
* @returns {Object} Resolution result with rollback state and user notification
*/
handleConflict(componentId, operationId, serverState, conflict = {}) {
const operation = this.getPendingOperation(componentId, operationId);
if (!operation) {
console.warn(`[OptimisticUI] Cannot handle conflict for unknown operation: ${operationId}`);
return { state: serverState, notification: null };
}
// Mark operation as failed
operation.status = 'failed';
// Get snapshot for rollback
const snapshot = this.snapshots.get(componentId);
console.warn(`[OptimisticUI] Version conflict detected for ${componentId}`, {
operationId,
expectedVersion: operation.expectedVersion,
serverVersion: serverState.version,
metadata: operation.metadata,
pendingOperationsCount: this.getPendingOperationsCount(componentId)
});
// Clear all pending operations (cascade rollback)
this.clearPendingOperations(componentId);
// Clear snapshot
this.clearSnapshot(componentId);
// Call conflict handler if registered
const conflictHandler = this.conflictHandlers.get(componentId);
if (conflictHandler) {
conflictHandler({
operation,
serverState,
snapshotState: snapshot?.state,
conflict
});
}
// Create user notification
const notification = {
type: 'conflict',
title: 'Update Conflict',
message: 'Your changes conflicted with another update. The latest version has been loaded.',
action: operation.metadata.action || 'unknown',
canRetry: true,
operation
};
return {
state: serverState,
notification
};
}
/**
* Create state snapshot for rollback
*
* @param {string} componentId - Component identifier
* @param {Object} state - Current state to snapshot
*/
createSnapshot(componentId, state) {
this.snapshots.set(componentId, {
state: JSON.parse(JSON.stringify(state)), // Deep clone
timestamp: Date.now()
});
console.log(`[OptimisticUI] Created snapshot for ${componentId}`, {
version: state.version
});
}
/**
* Clear snapshot after all operations confirmed
*
* @param {string} componentId - Component identifier
*/
clearSnapshot(componentId) {
const snapshot = this.snapshots.get(componentId);
if (snapshot) {
this.snapshots.delete(componentId);
console.log(`[OptimisticUI] Cleared snapshot for ${componentId}`);
}
}
/**
* Get rollback snapshot
*
* @param {string} componentId - Component identifier
* @returns {Object|null} Snapshot or null if none exists
*/
getSnapshot(componentId) {
const snapshot = this.snapshots.get(componentId);
return snapshot ? snapshot.state : null;
}
/**
* Add pending operation
*
* @param {string} componentId - Component identifier
* @param {Object} operation - Operation object
*/
addPendingOperation(componentId, operation) {
if (!this.pendingOperations.has(componentId)) {
this.pendingOperations.set(componentId, []);
}
this.pendingOperations.get(componentId).push(operation);
}
/**
* Remove pending operation
*
* @param {string} componentId - Component identifier
* @param {string} operationId - Operation ID to remove
*/
removePendingOperation(componentId, operationId) {
const operations = this.pendingOperations.get(componentId);
if (!operations) {
return;
}
const index = operations.findIndex(op => op.id === operationId);
if (index !== -1) {
operations.splice(index, 1);
}
// Clean up empty arrays
if (operations.length === 0) {
this.pendingOperations.delete(componentId);
}
}
/**
* Get specific pending operation
*
* @param {string} componentId - Component identifier
* @param {string} operationId - Operation ID
* @returns {Object|null} Operation or null if not found
*/
getPendingOperation(componentId, operationId) {
const operations = this.pendingOperations.get(componentId);
if (!operations) {
return null;
}
return operations.find(op => op.id === operationId) || null;
}
/**
* Get all pending operations for component
*
* @param {string} componentId - Component identifier
* @returns {Array<Object>} Array of pending operations
*/
getPendingOperations(componentId) {
return this.pendingOperations.get(componentId) || [];
}
/**
* Get pending operations count
*
* @param {string} componentId - Component identifier
* @returns {number} Number of pending operations
*/
getPendingOperationsCount(componentId) {
const operations = this.pendingOperations.get(componentId);
return operations ? operations.length : 0;
}
/**
* Check if component has pending operations
*
* @param {string} componentId - Component identifier
* @returns {boolean} True if has pending operations
*/
hasPendingOperations(componentId) {
return this.getPendingOperationsCount(componentId) > 0;
}
/**
* Clear all pending operations for component
*
* @param {string} componentId - Component identifier
*/
clearPendingOperations(componentId) {
this.pendingOperations.delete(componentId);
}
/**
* Register conflict handler
*
* Allows components to provide custom conflict resolution UI.
*
* @param {string} componentId - Component identifier
* @param {Function} handler - Conflict handler callback
*/
registerConflictHandler(componentId, handler) {
this.conflictHandlers.set(componentId, handler);
}
/**
* Unregister conflict handler
*
* @param {string} componentId - Component identifier
*/
unregisterConflictHandler(componentId) {
this.conflictHandlers.delete(componentId);
}
/**
* Generate unique operation ID
*
* @returns {string} Unique operation ID
*/
generateOperationId() {
return `op-${++this.operationIdCounter}-${Date.now()}`;
}
/**
* Retry failed operation
*
* Allows user to retry operation after conflict resolution.
*
* @param {string} componentId - Component identifier
* @param {Object} operation - Failed operation to retry
* @param {Function} retryCallback - Callback to execute retry
* @returns {Promise} Retry promise
*/
async retryOperation(componentId, operation, retryCallback) {
console.log(`[OptimisticUI] Retrying operation ${operation.id} for ${componentId}`);
try {
// Execute retry callback
const result = await retryCallback(operation.metadata);
console.log(`[OptimisticUI] Retry succeeded for ${operation.id}`);
return result;
} catch (error) {
console.error(`[OptimisticUI] Retry failed for ${operation.id}:`, error);
throw error;
}
}
/**
* Get debugging stats
*
* @returns {Object} Statistics about optimistic operations
*/
getStats() {
const stats = {
total_components: this.pendingOperations.size,
total_pending_operations: 0,
total_snapshots: this.snapshots.size,
components: {}
};
this.pendingOperations.forEach((operations, componentId) => {
stats.total_pending_operations += operations.length;
stats.components[componentId] = {
pending: operations.length,
has_snapshot: this.snapshots.has(componentId)
};
});
return stats;
}
/**
* Clear all state (for testing/debugging)
*/
clear() {
this.pendingOperations.clear();
this.snapshots.clear();
this.conflictHandlers.clear();
this.operationIdCounter = 0;
console.log('[OptimisticUI] Cleared all state');
}
}
// Create singleton instance
export const optimisticStateManager = new OptimisticStateManager();
// Export for testing and direct use
export default optimisticStateManager;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,181 @@
/**
* Performance Profiler Module
*
* Provides performance profiling with flamegraph and timeline visualization
* for LiveComponents and general application performance analysis.
*
* @module performance-profiler
*/
import { PerformanceProfiler, LiveComponentsProfiler } from './profiler.js';
import { FlamegraphVisualizer, TimelineVisualizer } from './visualizer.js';
export const definition = {
name: 'performance-profiler',
version: '1.0.0',
dependencies: [],
provides: ['performance-profiling', 'flamegraph', 'timeline'],
priority: 0
};
let globalProfiler = null;
let flamegraphViz = null;
let timelineViz = null;
/**
* Initialize performance profiler module
* @param {Object} config - Module configuration
* @param {Object} state - Module state manager
*/
export async function init(config = {}, state) {
console.log('[PerformanceProfiler] Initializing performance profiler module');
const enabled = config.enabled ?? false;
// Create global profiler instance
globalProfiler = new PerformanceProfiler({
enabled,
maxSamples: config.maxSamples ?? 1000,
samplingInterval: config.samplingInterval ?? 10,
autoStart: config.autoStart ?? false
});
// Initialize visualizers if containers exist
if (config.flamegraphContainer) {
const container = document.querySelector(config.flamegraphContainer);
if (container) {
flamegraphViz = new FlamegraphVisualizer(container, config.flamegraph ?? {});
console.log('[PerformanceProfiler] Flamegraph visualizer initialized');
}
}
if (config.timelineContainer) {
const container = document.querySelector(config.timelineContainer);
if (container) {
timelineViz = new TimelineVisualizer(container, config.timeline ?? {});
console.log('[PerformanceProfiler] Timeline visualizer initialized');
}
}
// Expose global API
if (typeof window !== 'undefined') {
window.PerformanceProfiler = {
profiler: globalProfiler,
flamegraph: flamegraphViz,
timeline: timelineViz,
// Convenience methods
start: () => globalProfiler.start(),
stop: () => {
const results = globalProfiler.stop();
if (results) {
console.log('[PerformanceProfiler] Profiling results:', results);
// Auto-render visualizations if available
if (flamegraphViz) {
const flamegraphData = globalProfiler.generateFlamegraph();
flamegraphViz.render(flamegraphData);
}
if (timelineViz) {
const timelineData = globalProfiler.generateTimeline();
timelineViz.render(timelineData);
}
}
return results;
},
mark: (name, metadata) => globalProfiler.mark(name, metadata),
measure: (name, start, end) => globalProfiler.measure(name, start, end),
exportChromeTrace: () => globalProfiler.exportToChromeTrace(),
createComponentProfiler: (component, options) => {
return new LiveComponentsProfiler(component, {
...options,
enabled: enabled || options?.enabled
});
}
};
console.log('[PerformanceProfiler] Global API available at window.PerformanceProfiler');
}
// Auto-instrument LiveComponents if available
if (typeof window !== 'undefined' && window.LiveComponents && config.autoInstrument !== false) {
instrumentLiveComponents();
}
console.log('[PerformanceProfiler] Module initialized');
}
/**
* Auto-instrument all LiveComponents
* @private
*/
function instrumentLiveComponents() {
const liveComponents = window.LiveComponents;
if (!liveComponents || !liveComponents.registry) {
console.warn('[PerformanceProfiler] LiveComponents not available for instrumentation');
return;
}
// Instrument existing components
for (const [id, component] of liveComponents.registry.entries()) {
const profiler = new LiveComponentsProfiler(component, {
enabled: true
});
// Store profiler reference
component._profiler = profiler;
console.log(`[PerformanceProfiler] Instrumented component: ${id}`);
}
// Instrument future components
const originalRegister = liveComponents.register.bind(liveComponents);
liveComponents.register = (id, component) => {
const result = originalRegister(id, component);
const profiler = new LiveComponentsProfiler(component, {
enabled: true
});
component._profiler = profiler;
console.log(`[PerformanceProfiler] Instrumented component: ${id}`);
return result;
};
console.log('[PerformanceProfiler] LiveComponents instrumentation enabled');
}
/**
* Destroy performance profiler module
*/
export function destroy() {
if (globalProfiler) {
globalProfiler.clear();
}
if (flamegraphViz) {
flamegraphViz.clear();
}
if (timelineViz) {
timelineViz.clear();
}
if (typeof window !== 'undefined') {
delete window.PerformanceProfiler;
}
console.log('[PerformanceProfiler] Module destroyed');
}
// Export classes for advanced usage
export { PerformanceProfiler, LiveComponentsProfiler, FlamegraphVisualizer, TimelineVisualizer };
export default { init, destroy, definition };

View File

@@ -0,0 +1,500 @@
/**
* LiveComponents Performance Profiler
*
* Provides detailed performance profiling with flamegraph and timeline visualization.
*
* Features:
* - Real-time performance metrics collection
* - Flamegraph generation for call stack visualization
* - Timeline visualization for event sequencing
* - Export to Chrome DevTools format
* - Integration with Performance API
*
* @module PerformanceProfiler
*/
export class PerformanceProfiler {
constructor(options = {}) {
this.enabled = options.enabled ?? false;
this.maxSamples = options.maxSamples ?? 1000;
this.samplingInterval = options.samplingInterval ?? 10; // ms
this.autoStart = options.autoStart ?? false;
this.samples = [];
this.timeline = [];
this.marks = new Map();
this.measures = new Map();
this.isRecording = false;
this.recordingStartTime = null;
if (this.autoStart && this.enabled) {
this.start();
}
}
/**
* Start performance profiling
*/
start() {
if (this.isRecording) {
console.warn('[PerformanceProfiler] Already recording');
return;
}
this.isRecording = true;
this.recordingStartTime = performance.now();
this.samples = [];
this.timeline = [];
console.log('[PerformanceProfiler] Started recording');
}
/**
* Stop performance profiling
* @returns {Object} Profiling results
*/
stop() {
if (!this.isRecording) {
console.warn('[PerformanceProfiler] Not recording');
return null;
}
this.isRecording = false;
const duration = performance.now() - this.recordingStartTime;
const results = {
duration,
samples: this.samples,
timeline: this.timeline,
marks: Array.from(this.marks.entries()),
measures: Array.from(this.measures.entries()),
summary: this.generateSummary()
};
console.log('[PerformanceProfiler] Stopped recording', results);
return results;
}
/**
* Mark a specific point in time
* @param {string} name - Mark name
* @param {Object} metadata - Additional metadata
*/
mark(name, metadata = {}) {
if (!this.isRecording && !this.enabled) return;
const timestamp = performance.now();
const mark = {
name,
timestamp,
relativeTime: timestamp - this.recordingStartTime,
metadata
};
this.marks.set(name, mark);
// Also use native Performance API
if (performance.mark) {
performance.mark(name);
}
// Add to timeline
this.timeline.push({
type: 'mark',
...mark
});
}
/**
* Measure duration between two marks
* @param {string} name - Measure name
* @param {string} startMark - Start mark name
* @param {string} endMark - End mark name (optional, defaults to now)
*/
measure(name, startMark, endMark = null) {
if (!this.isRecording && !this.enabled) return;
const start = this.marks.get(startMark);
if (!start) {
console.warn(`[PerformanceProfiler] Start mark "${startMark}" not found`);
return;
}
const endTime = endMark
? this.marks.get(endMark)?.timestamp
: performance.now();
if (!endTime) {
console.warn(`[PerformanceProfiler] End mark "${endMark}" not found`);
return;
}
const duration = endTime - start.timestamp;
const measure = {
name,
startMark,
endMark,
duration,
startTime: start.timestamp,
endTime,
relativeStartTime: start.relativeTime,
relativeEndTime: endTime - this.recordingStartTime
};
this.measures.set(name, measure);
// Native Performance API
if (performance.measure && endMark) {
try {
performance.measure(name, startMark, endMark);
} catch (e) {
// Ignore if marks don't exist in native API
}
}
// Add to timeline
this.timeline.push({
type: 'measure',
...measure
});
}
/**
* Record a sample for flamegraph
* @param {string} functionName - Function name
* @param {Array<string>} stackTrace - Call stack
* @param {number} duration - Execution duration
*/
sample(functionName, stackTrace = [], duration = 0) {
if (!this.isRecording) return;
if (this.samples.length >= this.maxSamples) {
console.warn('[PerformanceProfiler] Max samples reached');
return;
}
this.samples.push({
timestamp: performance.now(),
relativeTime: performance.now() - this.recordingStartTime,
functionName,
stackTrace,
duration
});
}
/**
* Generate flamegraph data
* @returns {Object} Flamegraph data structure
*/
generateFlamegraph() {
if (this.samples.length === 0) {
return null;
}
// Build call tree from samples
const root = {
name: '(root)',
value: 0,
children: []
};
for (const sample of this.samples) {
let currentNode = root;
const stack = [sample.functionName, ...sample.stackTrace].reverse();
for (const funcName of stack) {
let child = currentNode.children.find(c => c.name === funcName);
if (!child) {
child = {
name: funcName,
value: 0,
children: []
};
currentNode.children.push(child);
}
child.value += sample.duration || 1;
currentNode = child;
}
}
return root;
}
/**
* Generate timeline data for visualization
* @returns {Array} Timeline events
*/
generateTimeline() {
return this.timeline.map(event => ({
...event,
category: this.categorizeEvent(event),
color: this.getEventColor(event)
}));
}
/**
* Generate summary statistics
* @returns {Object} Summary data
*/
generateSummary() {
const durations = Array.from(this.measures.values()).map(m => m.duration);
if (durations.length === 0) {
return {
totalMeasures: 0,
totalSamples: this.samples.length,
totalDuration: 0
};
}
const totalDuration = durations.reduce((sum, d) => sum + d, 0);
const avgDuration = totalDuration / durations.length;
const maxDuration = Math.max(...durations);
const minDuration = Math.min(...durations);
// Calculate percentiles
const sortedDurations = [...durations].sort((a, b) => a - b);
const p50 = sortedDurations[Math.floor(sortedDurations.length * 0.5)];
const p90 = sortedDurations[Math.floor(sortedDurations.length * 0.9)];
const p99 = sortedDurations[Math.floor(sortedDurations.length * 0.99)];
return {
totalMeasures: this.measures.size,
totalSamples: this.samples.length,
totalDuration,
avgDuration,
maxDuration,
minDuration,
percentiles: { p50, p90, p99 }
};
}
/**
* Export to Chrome DevTools Performance format
* @returns {Object} Chrome DevTools trace event format
*/
exportToChromeTrace() {
const events = [];
// Add marks
for (const [name, mark] of this.marks.entries()) {
events.push({
name,
cat: 'mark',
ph: 'R', // Instant event
ts: mark.timestamp * 1000, // microseconds
pid: 1,
tid: 1,
args: mark.metadata
});
}
// Add measures as duration events
for (const [name, measure] of this.measures.entries()) {
events.push({
name,
cat: 'measure',
ph: 'B', // Begin
ts: measure.startTime * 1000,
pid: 1,
tid: 1
});
events.push({
name,
cat: 'measure',
ph: 'E', // End
ts: measure.endTime * 1000,
pid: 1,
tid: 1
});
}
// Add samples
for (const sample of this.samples) {
events.push({
name: sample.functionName,
cat: 'sample',
ph: 'X', // Complete event
ts: sample.timestamp * 1000,
dur: (sample.duration || 1) * 1000,
pid: 1,
tid: 1,
args: {
stackTrace: sample.stackTrace
}
});
}
return {
traceEvents: events.sort((a, b) => a.ts - b.ts),
displayTimeUnit: 'ms',
metadata: {
'clock-domain': 'PERFORMANCE_NOW'
}
};
}
/**
* Categorize timeline event
* @private
*/
categorizeEvent(event) {
if (event.type === 'mark') {
if (event.name.includes('action')) return 'action';
if (event.name.includes('render')) return 'render';
if (event.name.includes('state')) return 'state';
return 'other';
}
if (event.type === 'measure') {
if (event.duration > 100) return 'slow';
if (event.duration > 16) return 'normal';
return 'fast';
}
return 'unknown';
}
/**
* Get color for event visualization
* @private
*/
getEventColor(event) {
const colorMap = {
action: '#4CAF50',
render: '#2196F3',
state: '#FF9800',
slow: '#F44336',
normal: '#FFC107',
fast: '#8BC34A',
other: '#9E9E9E',
unknown: '#607D8B'
};
return colorMap[this.categorizeEvent(event)] || '#607D8B';
}
/**
* Clear all profiling data
*/
clear() {
this.samples = [];
this.timeline = [];
this.marks.clear();
this.measures.clear();
this.recordingStartTime = null;
// Clear native Performance API
if (performance.clearMarks) {
performance.clearMarks();
}
if (performance.clearMeasures) {
performance.clearMeasures();
}
}
/**
* Get current profiling status
* @returns {Object} Status information
*/
getStatus() {
return {
enabled: this.enabled,
isRecording: this.isRecording,
samplesCount: this.samples.length,
marksCount: this.marks.size,
measuresCount: this.measures.size,
timelineEventsCount: this.timeline.length,
recordingDuration: this.isRecording
? performance.now() - this.recordingStartTime
: 0
};
}
}
/**
* LiveComponents Performance Integration
*
* Automatic profiling for LiveComponents actions and lifecycle events
*/
export class LiveComponentsProfiler extends PerformanceProfiler {
constructor(component, options = {}) {
super(options);
this.component = component;
this.actionCallDepth = 0;
if (this.enabled) {
this.instrumentComponent();
}
}
/**
* Instrument component for automatic profiling
* @private
*/
instrumentComponent() {
// Hook into action calls
const originalCall = this.component.call.bind(this.component);
this.component.call = (actionName, params, options) => {
this.actionCallDepth++;
const markName = `action:${actionName}:${this.actionCallDepth}`;
this.mark(`${markName}:start`, {
actionName,
params,
depth: this.actionCallDepth
});
const result = originalCall(actionName, params, options);
// Handle promise results
if (result instanceof Promise) {
return result.finally(() => {
this.mark(`${markName}:end`);
this.measure(`action:${actionName}`, `${markName}:start`, `${markName}:end`);
this.actionCallDepth--;
});
}
this.mark(`${markName}:end`);
this.measure(`action:${actionName}`, `${markName}:start`, `${markName}:end`);
this.actionCallDepth--;
return result;
};
// Hook into state updates
if (this.component.state) {
const originalSet = this.component.state.set.bind(this.component.state);
this.component.state.set = (key, value) => {
this.mark(`state:set:${key}`, { key, value });
return originalSet(key, value);
};
}
// Hook into renders
if (this.component.render) {
const originalRender = this.component.render.bind(this.component);
this.component.render = () => {
this.mark('render:start');
const result = originalRender();
if (result instanceof Promise) {
return result.finally(() => {
this.mark('render:end');
this.measure('render', 'render:start', 'render:end');
});
}
this.mark('render:end');
this.measure('render', 'render:start', 'render:end');
return result;
};
}
}
}
// Export default instance
export default PerformanceProfiler;

View File

@@ -0,0 +1,635 @@
/**
* Performance Visualization Components
*
* Provides flamegraph and timeline visualization for performance profiling data.
*
* @module PerformanceVisualizer
*/
/**
* Flamegraph Visualizer
*
* Renders interactive flamegraph from profiling samples
*/
export class FlamegraphVisualizer {
constructor(container, options = {}) {
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
if (!this.container) {
throw new Error('Container element not found');
}
this.options = {
width: options.width ?? this.container.clientWidth,
height: options.height ?? 400,
barHeight: options.barHeight ?? 20,
barPadding: options.barPadding ?? 2,
colorScheme: options.colorScheme ?? 'category', // 'category', 'duration', 'monochrome'
minWidth: options.minWidth ?? 0.5, // Minimum width in pixels to render
...options
};
this.data = null;
this.svg = null;
this.tooltip = null;
this.selectedNode = null;
this.init();
}
/**
* Initialize SVG canvas
* @private
*/
init() {
// Create SVG element
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.svg.setAttribute('width', this.options.width);
this.svg.setAttribute('height', this.options.height);
this.svg.style.fontFamily = 'monospace';
this.svg.style.fontSize = '12px';
// Create tooltip
this.tooltip = document.createElement('div');
this.tooltip.style.position = 'absolute';
this.tooltip.style.padding = '8px';
this.tooltip.style.background = 'rgba(0, 0, 0, 0.8)';
this.tooltip.style.color = 'white';
this.tooltip.style.borderRadius = '4px';
this.tooltip.style.pointerEvents = 'none';
this.tooltip.style.display = 'none';
this.tooltip.style.zIndex = '1000';
this.tooltip.style.fontSize = '12px';
this.container.style.position = 'relative';
this.container.appendChild(this.svg);
this.container.appendChild(this.tooltip);
}
/**
* Render flamegraph from data
* @param {Object} data - Flamegraph data (hierarchical structure)
*/
render(data) {
this.data = data;
this.clear();
if (!data) {
return;
}
// Calculate total value for width scaling
const totalValue = this.calculateTotalValue(data);
// Render nodes recursively
this.renderNode(data, 0, 0, this.options.width, totalValue);
}
/**
* Render a single flamegraph node
* @private
*/
renderNode(node, x, y, width, totalValue) {
const { barHeight, barPadding, minWidth } = this.options;
// Skip if too small to render
if (width < minWidth) {
return;
}
// Create rectangle
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('x', x);
rect.setAttribute('y', y);
rect.setAttribute('width', width);
rect.setAttribute('height', barHeight - barPadding);
rect.setAttribute('fill', this.getColor(node));
rect.setAttribute('stroke', '#fff');
rect.setAttribute('stroke-width', '0.5');
rect.style.cursor = 'pointer';
// Add interactivity
rect.addEventListener('mouseenter', (e) => this.showTooltip(e, node));
rect.addEventListener('mouseleave', () => this.hideTooltip());
rect.addEventListener('click', () => this.selectNode(node));
this.svg.appendChild(rect);
// Create text label
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', x + 4);
text.setAttribute('y', y + barHeight / 2 + 4);
text.setAttribute('fill', this.getTextColor(node));
text.setAttribute('pointer-events', 'none');
text.textContent = this.truncateText(node.name, width);
this.svg.appendChild(text);
// Render children
if (node.children && node.children.length > 0) {
let childX = x;
const childY = y + barHeight;
for (const child of node.children) {
const childWidth = (child.value / node.value) * width;
this.renderNode(child, childX, childY, childWidth, totalValue);
childX += childWidth;
}
}
}
/**
* Calculate total value in tree
* @private
*/
calculateTotalValue(node) {
if (!node.children || node.children.length === 0) {
return node.value;
}
return node.children.reduce((sum, child) => {
return sum + this.calculateTotalValue(child);
}, 0);
}
/**
* Get color for node based on color scheme
* @private
*/
getColor(node) {
if (this.options.colorScheme === 'monochrome') {
return '#3b82f6';
}
if (this.options.colorScheme === 'duration') {
// Color based on node value (duration)
const intensity = Math.min(node.value / 100, 1);
const r = Math.floor(255 * intensity);
const g = Math.floor(255 * (1 - intensity));
return `rgb(${r}, ${g}, 100)`;
}
// Category-based coloring (default)
const colorMap = {
'action': '#4CAF50',
'render': '#2196F3',
'state': '#FF9800',
'network': '#9C27B0',
'dom': '#F44336',
'compute': '#00BCD4'
};
// Determine category from function name
for (const [keyword, color] of Object.entries(colorMap)) {
if (node.name.toLowerCase().includes(keyword)) {
return color;
}
}
// Hash-based color for consistency
return this.hashColor(node.name);
}
/**
* Get text color for contrast
* @private
*/
getTextColor(node) {
return '#fff'; // White text for all colors (good contrast)
}
/**
* Hash function for consistent colors
* @private
*/
hashColor(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = hash % 360;
return `hsl(${hue}, 70%, 50%)`;
}
/**
* Truncate text to fit width
* @private
*/
truncateText(text, width) {
const charWidth = 7; // Approximate character width
const maxChars = Math.floor(width / charWidth) - 2;
if (text.length <= maxChars) {
return text;
}
return text.substring(0, maxChars) + '…';
}
/**
* Show tooltip with node information
* @private
*/
showTooltip(event, node) {
const percentage = this.data
? ((node.value / this.calculateTotalValue(this.data)) * 100).toFixed(2)
: '0';
this.tooltip.innerHTML = `
<strong>${node.name}</strong><br>
Time: ${node.value.toFixed(2)}ms<br>
${percentage}% of total<br>
${node.children ? `${node.children.length} child(ren)` : 'Leaf node'}
`;
this.tooltip.style.display = 'block';
this.tooltip.style.left = `${event.pageX + 10}px`;
this.tooltip.style.top = `${event.pageY + 10}px`;
}
/**
* Hide tooltip
* @private
*/
hideTooltip() {
this.tooltip.style.display = 'none';
}
/**
* Select node (zoom/highlight)
* @private
*/
selectNode(node) {
this.selectedNode = node;
console.log('[Flamegraph] Selected node:', node);
// Could implement zoom functionality here
// this.render(node);
}
/**
* Clear flamegraph
*/
clear() {
while (this.svg.firstChild) {
this.svg.removeChild(this.svg.firstChild);
}
}
/**
* Export flamegraph as SVG
* @returns {string} SVG string
*/
exportSVG() {
return this.svg.outerHTML;
}
/**
* Export flamegraph as PNG
* @returns {Promise<Blob>} PNG image blob
*/
async exportPNG() {
const svgData = new XMLSerializer().serializeToString(this.svg);
const canvas = document.createElement('canvas');
canvas.width = this.options.width;
canvas.height = this.options.height;
const ctx = canvas.getContext('2d');
const img = new Image();
return new Promise((resolve, reject) => {
img.onload = () => {
ctx.drawImage(img, 0, 0);
canvas.toBlob(resolve, 'image/png');
};
img.onerror = reject;
img.src = 'data:image/svg+xml;base64,' + btoa(svgData);
});
}
}
/**
* Timeline Visualizer
*
* Renders interactive timeline from profiling events
*/
export class TimelineVisualizer {
constructor(container, options = {}) {
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
if (!this.container) {
throw new Error('Container element not found');
}
this.options = {
width: options.width ?? this.container.clientWidth,
height: options.height ?? 200,
trackHeight: options.trackHeight ?? 30,
padding: options.padding ?? { top: 20, right: 20, bottom: 30, left: 60 },
...options
};
this.events = [];
this.canvas = null;
this.ctx = null;
this.tooltip = null;
this.scale = { x: 1, y: 1 };
this.offset = { x: 0, y: 0 };
this.init();
}
/**
* Initialize canvas
* @private
*/
init() {
// Create canvas
this.canvas = document.createElement('canvas');
this.canvas.width = this.options.width;
this.canvas.height = this.options.height;
this.canvas.style.cursor = 'crosshair';
this.ctx = this.canvas.getContext('2d');
// Create tooltip
this.tooltip = document.createElement('div');
this.tooltip.style.position = 'absolute';
this.tooltip.style.padding = '8px';
this.tooltip.style.background = 'rgba(0, 0, 0, 0.8)';
this.tooltip.style.color = 'white';
this.tooltip.style.borderRadius = '4px';
this.tooltip.style.pointerEvents = 'none';
this.tooltip.style.display = 'none';
this.tooltip.style.zIndex = '1000';
this.tooltip.style.fontSize = '12px';
this.container.style.position = 'relative';
this.container.appendChild(this.canvas);
this.container.appendChild(this.tooltip);
// Add mouse events
this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
this.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
}
/**
* Render timeline from events
* @param {Array} events - Timeline events
*/
render(events) {
this.events = events;
this.clear();
if (!events || events.length === 0) {
return;
}
// Calculate time range
const times = events.map(e => e.timestamp || e.relativeTime || 0);
const minTime = Math.min(...times);
const maxTime = Math.max(...times);
const duration = maxTime - minTime;
// Calculate scale
const { padding } = this.options;
const plotWidth = this.options.width - padding.left - padding.right;
const plotHeight = this.options.height - padding.top - padding.bottom;
this.scale.x = plotWidth / duration;
// Draw axes
this.drawAxes(minTime, maxTime, duration);
// Draw events
const tracks = this.organizeIntoTracks(events);
this.scale.y = plotHeight / tracks.length;
tracks.forEach((track, index) => {
track.forEach(event => {
this.drawEvent(event, index, minTime);
});
});
}
/**
* Organize events into non-overlapping tracks
* @private
*/
organizeIntoTracks(events) {
const tracks = [];
const sortedEvents = [...events].sort((a, b) => {
const aTime = a.timestamp || a.relativeTime || 0;
const bTime = b.timestamp || b.relativeTime || 0;
return aTime - bTime;
});
for (const event of sortedEvents) {
const eventStart = event.timestamp || event.relativeTime || 0;
const eventEnd = event.type === 'measure'
? eventStart + event.duration
: eventStart + 0.1;
// Find track where event fits
let placed = false;
for (const track of tracks) {
const conflicts = track.some(e => {
const eStart = e.timestamp || e.relativeTime || 0;
const eEnd = e.type === 'measure'
? eStart + e.duration
: eStart + 0.1;
return eventStart < eEnd && eventEnd > eStart;
});
if (!conflicts) {
track.push(event);
placed = true;
break;
}
}
if (!placed) {
tracks.push([event]);
}
}
return tracks;
}
/**
* Draw axes and grid
* @private
*/
drawAxes(minTime, maxTime, duration) {
const { padding } = this.options;
const { ctx } = this;
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 1;
// Y-axis
ctx.beginPath();
ctx.moveTo(padding.left, padding.top);
ctx.lineTo(padding.left, this.options.height - padding.bottom);
ctx.stroke();
// X-axis
ctx.beginPath();
ctx.moveTo(padding.left, this.options.height - padding.bottom);
ctx.lineTo(this.options.width - padding.right, this.options.height - padding.bottom);
ctx.stroke();
// Time labels
ctx.fillStyle = '#666';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
const numLabels = 5;
for (let i = 0; i <= numLabels; i++) {
const time = minTime + (duration / numLabels) * i;
const x = padding.left + (time - minTime) * this.scale.x;
const y = this.options.height - padding.bottom + 15;
ctx.fillText(`${time.toFixed(1)}ms`, x, y);
}
}
/**
* Draw a single event
* @private
*/
drawEvent(event, trackIndex, minTime) {
const { padding } = this.options;
const { ctx } = this;
const startTime = event.timestamp || event.relativeTime || 0;
const x = padding.left + (startTime - minTime) * this.scale.x;
const y = padding.top + trackIndex * this.scale.y;
const height = this.scale.y - 4;
if (event.type === 'mark') {
// Draw mark as vertical line
ctx.strokeStyle = event.color || '#2196F3';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x, y + height);
ctx.stroke();
// Draw marker
ctx.fillStyle = event.color || '#2196F3';
ctx.beginPath();
ctx.arc(x, y + height / 2, 4, 0, Math.PI * 2);
ctx.fill();
} else if (event.type === 'measure') {
// Draw measure as rectangle
const width = Math.max(event.duration * this.scale.x, 2);
ctx.fillStyle = event.color || '#4CAF50';
ctx.fillRect(x, y, width, height);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 0.5;
ctx.strokeRect(x, y, width, height);
// Draw label if wide enough
if (width > 30) {
ctx.fillStyle = '#fff';
ctx.font = '10px monospace';
ctx.textAlign = 'left';
ctx.fillText(event.name, x + 4, y + height / 2 + 4);
}
}
// Store event bounds for hover detection
event._bounds = {
x,
y,
width: event.type === 'measure' ? event.duration * this.scale.x : 8,
height
};
}
/**
* Handle mouse move for tooltip
* @private
*/
handleMouseMove(e) {
const rect = this.canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Find hovered event
const hoveredEvent = this.events.find(event => {
if (!event._bounds) return false;
const { x, y, width, height } = event._bounds;
return mouseX >= x && mouseX <= x + width &&
mouseY >= y && mouseY <= y + height;
});
if (hoveredEvent) {
this.showTooltip(e, hoveredEvent);
} else {
this.hideTooltip();
}
}
/**
* Handle mouse leave
* @private
*/
handleMouseLeave() {
this.hideTooltip();
}
/**
* Show tooltip with event information
* @private
*/
showTooltip(event, timelineEvent) {
const time = timelineEvent.timestamp || timelineEvent.relativeTime || 0;
this.tooltip.innerHTML = `
<strong>${timelineEvent.name}</strong><br>
Type: ${timelineEvent.type}<br>
Time: ${time.toFixed(2)}ms<br>
${timelineEvent.duration ? `Duration: ${timelineEvent.duration.toFixed(2)}ms` : ''}
`;
this.tooltip.style.display = 'block';
this.tooltip.style.left = `${event.pageX + 10}px`;
this.tooltip.style.top = `${event.pageY + 10}px`;
}
/**
* Hide tooltip
* @private
*/
hideTooltip() {
this.tooltip.style.display = 'none';
}
/**
* Clear timeline
*/
clear() {
this.ctx.clearRect(0, 0, this.options.width, this.options.height);
}
/**
* Export timeline as PNG
* @returns {Promise<Blob>} PNG image blob
*/
async exportPNG() {
return new Promise((resolve) => {
this.canvas.toBlob(resolve, 'image/png');
});
}
}
export default { FlamegraphVisualizer, TimelineVisualizer };

View File

@@ -0,0 +1,422 @@
/**
* SSE (Server-Sent Events) Client Module
*
* Provides real-time server push capabilities for LiveComponents.
*
* Features:
* - Auto-reconnect with exponential backoff
* - Multi-channel subscription
* - Event type routing
* - Heartbeat monitoring
* - Connection state management
*
* @module sse
*/
/**
* Connection states
*/
const ConnectionState = {
DISCONNECTED: 'disconnected',
CONNECTING: 'connecting',
CONNECTED: 'connected',
ERROR: 'error'
};
/**
* Reconnection strategy with exponential backoff
*/
class ReconnectionStrategy {
constructor() {
this.attempt = 0;
this.baseDelay = 1000; // 1s
this.maxDelay = 30000; // 30s
this.maxAttempts = 10;
}
/**
* Get delay for current attempt with jitter
*/
getDelay() {
const delay = Math.min(
this.baseDelay * Math.pow(2, this.attempt),
this.maxDelay
);
// Add jitter (±25%)
const jitter = delay * 0.25 * (Math.random() - 0.5);
return Math.floor(delay + jitter);
}
/**
* Check if should retry
*/
shouldRetry() {
return this.attempt < this.maxAttempts;
}
/**
* Record a reconnection attempt
*/
recordAttempt() {
this.attempt++;
}
/**
* Reset strategy (connection successful)
*/
reset() {
this.attempt = 0;
}
}
/**
* SSE Client
*
* Manages Server-Sent Events connection with auto-reconnect.
*/
export class SseClient {
/**
* @param {string[]} channels - Channels to subscribe to
* @param {object} options - Configuration options
*/
constructor(channels = [], options = {}) {
this.channels = channels;
this.options = {
autoReconnect: true,
heartbeatTimeout: 45000, // 45s (server sends every 30s)
...options
};
this.eventSource = null;
this.state = ConnectionState.DISCONNECTED;
this.reconnectionStrategy = new ReconnectionStrategy();
this.reconnectTimer = null;
this.heartbeatTimer = null;
this.lastHeartbeat = null;
this.eventHandlers = new Map(); // eventType => Set<handler>
this.stateChangeHandlers = new Set();
this.connectionId = null;
}
/**
* Connect to SSE stream
*/
connect() {
if (this.state === ConnectionState.CONNECTING || this.state === ConnectionState.CONNECTED) {
console.warn('[SSE] Already connecting or connected');
return;
}
this.setState(ConnectionState.CONNECTING);
const url = this.buildUrl();
try {
this.eventSource = new EventSource(url);
// Connection opened
this.eventSource.addEventListener('open', () => {
console.log('[SSE] Connection opened');
this.setState(ConnectionState.CONNECTED);
this.reconnectionStrategy.reset();
this.startHeartbeatMonitoring();
});
// Connection error
this.eventSource.addEventListener('error', (e) => {
console.error('[SSE] Connection error', e);
if (this.eventSource.readyState === EventSource.CLOSED) {
this.handleDisconnect();
}
});
// Initial connection confirmation
this.eventSource.addEventListener('connected', (e) => {
const data = JSON.parse(e.data);
this.connectionId = data.connection_id;
console.log('[SSE] Connected with ID:', this.connectionId);
});
// Heartbeat event
this.eventSource.addEventListener('heartbeat', (e) => {
this.lastHeartbeat = Date.now();
});
// Disconnected event
this.eventSource.addEventListener('disconnected', () => {
console.log('[SSE] Server initiated disconnect');
this.disconnect();
});
// Error events
this.eventSource.addEventListener('error', (e) => {
const data = JSON.parse(e.data);
console.error('[SSE] Server error:', data);
});
} catch (error) {
console.error('[SSE] Failed to create EventSource', error);
this.setState(ConnectionState.ERROR);
this.scheduleReconnect();
}
}
/**
* Disconnect from SSE stream
*/
disconnect() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
this.stopHeartbeatMonitoring();
this.clearReconnectTimer();
this.setState(ConnectionState.DISCONNECTED);
this.connectionId = null;
}
/**
* Handle disconnection
*/
handleDisconnect() {
this.setState(ConnectionState.ERROR);
this.stopHeartbeatMonitoring();
if (this.options.autoReconnect && this.reconnectionStrategy.shouldRetry()) {
this.scheduleReconnect();
} else {
this.setState(ConnectionState.DISCONNECTED);
}
}
/**
* Schedule reconnection attempt
*/
scheduleReconnect() {
const delay = this.reconnectionStrategy.getDelay();
console.log(`[SSE] Reconnecting in ${delay}ms (attempt ${this.reconnectionStrategy.attempt + 1})`);
this.reconnectTimer = setTimeout(() => {
this.reconnectionStrategy.recordAttempt();
this.connect();
}, delay);
}
/**
* Clear reconnect timer
*/
clearReconnectTimer() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
/**
* Start heartbeat monitoring
*/
startHeartbeatMonitoring() {
this.lastHeartbeat = Date.now();
this.heartbeatTimer = setInterval(() => {
const timeSinceHeartbeat = Date.now() - this.lastHeartbeat;
if (timeSinceHeartbeat > this.options.heartbeatTimeout) {
console.warn('[SSE] Heartbeat timeout - connection appears dead');
this.handleDisconnect();
}
}, 5000); // Check every 5s
}
/**
* Stop heartbeat monitoring
*/
stopHeartbeatMonitoring() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/**
* Build SSE stream URL with channels
*/
buildUrl() {
const baseUrl = '/sse/stream';
const channelParam = this.channels.join(',');
return `${baseUrl}?channels=${encodeURIComponent(channelParam)}`;
}
/**
* Register event handler
*
* @param {string} eventType - Event type to listen for
* @param {function} handler - Event handler function
*/
on(eventType, handler) {
if (!this.eventHandlers.has(eventType)) {
this.eventHandlers.set(eventType, new Set());
// Register EventSource listener
if (this.eventSource) {
this.eventSource.addEventListener(eventType, (e) => {
this.handleEvent(eventType, e);
});
}
}
this.eventHandlers.get(eventType).add(handler);
return () => this.off(eventType, handler);
}
/**
* Unregister event handler
*/
off(eventType, handler) {
const handlers = this.eventHandlers.get(eventType);
if (handlers) {
handlers.delete(handler);
}
}
/**
* Handle incoming event
*/
handleEvent(eventType, event) {
const handlers = this.eventHandlers.get(eventType);
if (!handlers || handlers.size === 0) return;
let data;
try {
data = JSON.parse(event.data);
} catch (e) {
console.error('[SSE] Failed to parse event data', e);
return;
}
handlers.forEach(handler => {
try {
handler(data, event);
} catch (e) {
console.error('[SSE] Event handler error', e);
}
});
}
/**
* Set connection state
*/
setState(newState) {
const oldState = this.state;
this.state = newState;
if (oldState !== newState) {
this.stateChangeHandlers.forEach(handler => {
try {
handler(newState, oldState);
} catch (e) {
console.error('[SSE] State change handler error', e);
}
});
}
}
/**
* Register state change handler
*/
onStateChange(handler) {
this.stateChangeHandlers.add(handler);
return () => this.stateChangeHandlers.delete(handler);
}
/**
* Get current connection state
*/
getState() {
return this.state;
}
/**
* Check if connected
*/
isConnected() {
return this.state === ConnectionState.CONNECTED;
}
/**
* Get connection ID
*/
getConnectionId() {
return this.connectionId;
}
/**
* Subscribe to additional channels (requires reconnect)
*/
addChannels(...newChannels) {
const added = newChannels.filter(ch => !this.channels.includes(ch));
if (added.length > 0) {
this.channels.push(...added);
if (this.isConnected()) {
console.log('[SSE] Reconnecting with new channels:', added);
this.disconnect();
this.connect();
}
}
}
/**
* Unsubscribe from channels (requires reconnect)
*/
removeChannels(...channelsToRemove) {
const before = this.channels.length;
this.channels = this.channels.filter(ch => !channelsToRemove.includes(ch));
if (this.channels.length !== before && this.isConnected()) {
console.log('[SSE] Reconnecting with removed channels');
this.disconnect();
this.connect();
}
}
}
/**
* Global SSE client instance
*/
let globalSseClient = null;
/**
* Get or create global SSE client
*/
export function getGlobalSseClient(channels = []) {
if (!globalSseClient) {
globalSseClient = new SseClient(channels);
}
return globalSseClient;
}
/**
* Initialize global SSE client and connect
*/
export function initSse(channels = [], autoConnect = true) {
globalSseClient = new SseClient(channels);
if (autoConnect) {
globalSseClient.connect();
}
return globalSseClient;
}
export default {
SseClient,
getGlobalSseClient,
initSse,
ConnectionState
};

View File

@@ -0,0 +1,313 @@
/**
* Web Push Manager Module
*
* Handles Web Push Notification subscriptions and management.
*/
export class WebPushManager {
constructor(options = {}) {
this.apiBase = options.apiBase || '/api/push';
this.serviceWorkerUrl = options.serviceWorkerUrl || '/js/sw-push.js';
this.vapidPublicKey = options.vapidPublicKey || null;
this.onSubscriptionChange = options.onSubscriptionChange || null;
}
/**
* Initialize Web Push Manager
*/
async init() {
if (!('serviceWorker' in navigator)) {
throw new Error('Service Workers are not supported in this browser');
}
if (!('PushManager' in window)) {
throw new Error('Push API is not supported in this browser');
}
// Register Service Worker
await this.registerServiceWorker();
// Get VAPID public key from server if not provided
if (!this.vapidPublicKey) {
await this.fetchVapidPublicKey();
}
// Check current subscription status
const subscription = await this.getSubscription();
if (this.onSubscriptionChange) {
this.onSubscriptionChange(subscription);
}
return subscription !== null;
}
/**
* Register Service Worker
*/
async registerServiceWorker() {
try {
const registration = await navigator.serviceWorker.register(this.serviceWorkerUrl);
console.log('[WebPush] Service Worker registered', registration);
return registration;
} catch (error) {
console.error('[WebPush] Service Worker registration failed', error);
throw error;
}
}
/**
* Fetch VAPID public key from server
*/
async fetchVapidPublicKey() {
try {
const response = await fetch(`${this.apiBase}/vapid-key`);
const data = await response.json();
if (!data.public_key) {
throw new Error('VAPID public key not available');
}
this.vapidPublicKey = data.public_key;
console.log('[WebPush] VAPID public key fetched');
} catch (error) {
console.error('[WebPush] Failed to fetch VAPID public key', error);
throw error;
}
}
/**
* Request notification permission
*/
async requestPermission() {
const permission = await Notification.requestPermission();
console.log('[WebPush] Permission:', permission);
if (permission !== 'granted') {
throw new Error('Notification permission denied');
}
return permission;
}
/**
* Subscribe to push notifications
*/
async subscribe() {
try {
// Request permission first
await this.requestPermission();
// Get Service Worker registration
const registration = await navigator.serviceWorker.ready;
// Check if already subscribed
let subscription = await registration.pushManager.getSubscription();
if (subscription) {
console.log('[WebPush] Already subscribed', subscription);
} else {
// Subscribe
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey)
});
console.log('[WebPush] Subscribed', subscription);
}
// Send subscription to server
await this.sendSubscriptionToServer(subscription);
if (this.onSubscriptionChange) {
this.onSubscriptionChange(subscription);
}
return subscription;
} catch (error) {
console.error('[WebPush] Subscription failed', error);
throw error;
}
}
/**
* Unsubscribe from push notifications
*/
async unsubscribe() {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
console.log('[WebPush] Not subscribed');
return false;
}
// Unsubscribe from browser
const success = await subscription.unsubscribe();
if (success) {
console.log('[WebPush] Unsubscribed from browser');
// Remove from server
await this.removeSubscriptionFromServer(subscription);
if (this.onSubscriptionChange) {
this.onSubscriptionChange(null);
}
}
return success;
} catch (error) {
console.error('[WebPush] Unsubscribe failed', error);
throw error;
}
}
/**
* Get current subscription
*/
async getSubscription() {
try {
const registration = await navigator.serviceWorker.ready;
return await registration.pushManager.getSubscription();
} catch (error) {
console.error('[WebPush] Failed to get subscription', error);
return null;
}
}
/**
* Check if subscribed
*/
async isSubscribed() {
const subscription = await this.getSubscription();
return subscription !== null;
}
/**
* Send test notification
*/
async sendTestNotification(title, body) {
try {
const subscription = await this.getSubscription();
if (!subscription) {
throw new Error('Not subscribed');
}
const response = await fetch(`${this.apiBase}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
endpoint: subscription.endpoint,
title: title || 'Test Notification',
body: body || 'This is a test notification!'
})
});
const result = await response.json();
console.log('[WebPush] Test notification sent', result);
return result;
} catch (error) {
console.error('[WebPush] Test notification failed', error);
throw error;
}
}
/**
* Send subscription to server
*/
async sendSubscriptionToServer(subscription) {
try {
const subscriptionJson = subscription.toJSON();
const response = await fetch(`${this.apiBase}/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscriptionJson)
});
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
const result = await response.json();
console.log('[WebPush] Subscription sent to server', result);
return result;
} catch (error) {
console.error('[WebPush] Failed to send subscription to server', error);
throw error;
}
}
/**
* Remove subscription from server
*/
async removeSubscriptionFromServer(subscription) {
try {
const response = await fetch(`${this.apiBase}/unsubscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
endpoint: subscription.endpoint
})
});
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
const result = await response.json();
console.log('[WebPush] Subscription removed from server', result);
return result;
} catch (error) {
console.error('[WebPush] Failed to remove subscription from server', error);
throw error;
}
}
/**
* Convert Base64 URL to Uint8Array (for VAPID key)
*/
urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
/**
* Get notification permission status
*/
getPermissionStatus() {
return Notification.permission;
}
/**
* Check if browser supports Web Push
*/
static isSupported() {
return ('serviceWorker' in navigator) && ('PushManager' in window);
}
}
export default WebPushManager;

View File

@@ -0,0 +1,53 @@
/**
* Web Push Module Entry Point
*/
import { WebPushManager } from './WebPushManager.js';
export { WebPushManager };
/**
* Initialize WebPush Module
* Called by framework module system
*/
export function init(config = {}, context = null) {
const apiBase = config.apiBase || '/api/push';
// Use absolute path from site root
// This ensures the Service Worker has the correct scope
const serviceWorkerUrl = config.serviceWorkerUrl || '/js/sw-push.js';
// Check browser support
if (!WebPushManager.isSupported()) {
console.warn('⚠️ WebPush not supported in this browser');
console.warn('Service Worker support:', 'serviceWorker' in navigator);
console.warn('Push Manager support:', 'PushManager' in window);
return null;
}
try {
// Create global instance
window.webPushManager = new WebPushManager({
apiBase,
serviceWorkerUrl,
onSubscriptionChange: (subscription) => {
console.log('🔔 Push subscription changed:', subscription ? 'Subscribed' : 'Unsubscribed');
}
});
console.log('🔔 WebPush Manager initialized (use window.webPushManager)');
console.log('Service Worker URL:', serviceWorkerUrl);
console.log('API Base:', apiBase);
console.log('');
console.log('💡 Usage:');
console.log(' await window.webPushManager.init() // Initialize and register service worker');
console.log(' await window.webPushManager.subscribe() // Subscribe to notifications');
console.log(' await window.webPushManager.unsubscribe() // Unsubscribe');
console.log(' await window.webPushManager.sendTestNotification() // Send test');
return window.webPushManager;
} catch (error) {
console.error('❌ Failed to initialize WebPush Manager:', error);
return null;
}
}