/** * Hot Reload Client for Development * Connects to SSE stream and handles reload events */ export class HotReload { constructor(options = {}) { this.url = options.url || '/dev/hot-reload'; this.reconnectDelay = options.reconnectDelay || 5000; this.maxReconnectAttempts = options.maxReconnectAttempts || 10; this.debug = options.debug || false; this.eventSource = null; this.reconnectAttempts = 0; this.reconnectTimer = null; // Only enable in development if (this.isDevelopment()) { this.connect(); this.setupVisibilityHandlers(); } } /** * Check if we're in development mode */ isDevelopment() { return window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' || window.location.hostname.includes('.local'); } /** * Connect to SSE stream */ connect() { if (this.eventSource) { return; } this.log('Connecting to Hot Reload server...'); this.eventSource = new EventSource(this.url); // Connection opened this.eventSource.addEventListener('open', () => { this.log('Hot Reload connected'); this.reconnectAttempts = 0; this.showNotification('Hot Reload Connected', 'success'); }); // Handle reload events this.eventSource.addEventListener('reload', (event) => { const data = JSON.parse(event.data); this.handleReload(data); }); // Handle heartbeat this.eventSource.addEventListener('heartbeat', () => { this.log('Heartbeat received'); }); // Handle close this.eventSource.addEventListener('close', () => { this.log('Server requested close'); this.disconnect(); }); // Handle errors this.eventSource.addEventListener('error', (error) => { this.log('Connection error', error); this.disconnect(); this.scheduleReconnect(); }); } /** * Disconnect from SSE stream */ disconnect() { if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } /** * Schedule reconnection attempt */ scheduleReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { this.log('Max reconnection attempts reached'); this.showNotification('Hot Reload disconnected', 'error'); return; } this.reconnectAttempts++; const delay = Math.min(this.reconnectDelay * this.reconnectAttempts, 30000); this.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`); this.reconnectTimer = setTimeout(() => { this.connect(); }, delay); } /** * Handle reload event from server */ handleReload(data) { this.log('Reload event received', data); switch (data.type) { case 'css': this.reloadCSS(data.file); break; case 'hmr': this.handleHMR(data); break; case 'partial': this.reloadPartial(data); break; case 'full': default: this.reloadPage(); break; } } /** * Reload CSS without page refresh */ reloadCSS(file) { this.log('Reloading CSS:', file); const links = document.querySelectorAll('link[rel="stylesheet"]'); const timestamp = new Date().getTime(); links.forEach(link => { const href = link.getAttribute('href'); if (href) { // Remove existing timestamp const cleanHref = href.split('?')[0]; // Add new timestamp to force reload link.setAttribute('href', `${cleanHref}?t=${timestamp}`); } }); this.showNotification('CSS updated', 'info', 2000); } /** * Handle Hot Module Replacement */ handleHMR(data) { this.log('HMR not yet implemented, falling back to full reload'); this.reloadPage(); } /** * Reload partial content */ reloadPartial(data) { this.log('Partial reload not yet implemented, falling back to full reload'); this.reloadPage(); } /** * Reload the entire page */ reloadPage() { this.log('Reloading page...'); this.showNotification('Reloading...', 'info', 500); setTimeout(() => { window.location.reload(); }, 500); } /** * Show notification to user */ showNotification(message, type = 'info', duration = 3000) { // Remove existing notification const existing = document.getElementById('hot-reload-notification'); if (existing) { existing.remove(); } // Create notification element const notification = document.createElement('div'); notification.id = 'hot-reload-notification'; notification.textContent = message; notification.className = `hot-reload-notification hot-reload-notification--${type}`; // Style the notification notification.style.cssText = ` position: fixed; bottom: 20px; right: 20px; padding: 12px 20px; border-radius: 6px; font-family: system-ui, -apple-system, sans-serif; font-size: 14px; font-weight: 500; z-index: 999999; transition: opacity 0.3s ease; pointer-events: none; max-width: 300px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); `; // Type-specific styles switch(type) { case 'success': notification.style.backgroundColor = '#10b981'; notification.style.color = '#ffffff'; break; case 'error': notification.style.backgroundColor = '#ef4444'; notification.style.color = '#ffffff'; break; case 'warning': notification.style.backgroundColor = '#f59e0b'; notification.style.color = '#ffffff'; break; default: notification.style.backgroundColor = '#3b82f6'; notification.style.color = '#ffffff'; } document.body.appendChild(notification); // Auto-remove after duration if (duration > 0) { setTimeout(() => { notification.style.opacity = '0'; setTimeout(() => { notification.remove(); }, 300); }, duration); } } /** * Setup visibility handlers for reconnection */ setupVisibilityHandlers() { // Reconnect when tab becomes visible document.addEventListener('visibilitychange', () => { if (!document.hidden && !this.eventSource) { this.log('Tab became visible, reconnecting...'); this.connect(); } }); // Disconnect when window is closed window.addEventListener('beforeunload', () => { this.disconnect(); }); } /** * Debug logging */ log(...args) { if (this.debug) { console.log('[HotReload]', ...args); } } } // Auto-initialize if in development if (typeof window !== 'undefined') { window.addEventListener('DOMContentLoaded', () => { // Only initialize if not already initialized if (!window.__hotReload) { window.__hotReload = new HotReload({ debug: true // Enable debug in development }); } }); }