UNPKG

@magnusbag/livets-core

Version:

TypeScript API layer for LiveTS framework

605 lines (592 loc) 23.8 kB
"use strict"; /** * LiveTS Server - HTTP and WebSocket server for LiveTS applications */ Object.defineProperty(exports, "__esModule", { value: true }); exports.LiveTSServer = void 0; const fs_1 = require("fs"); // Import the Rust core engine let LiveTSEngine; let LiveTSWebSocketBroker; try { const rustCore = require('@magnusbag/livets-rust-core'); LiveTSEngine = rustCore.LiveTsEngine; // Note: lowercase 's' in generated binding LiveTSWebSocketBroker = rustCore.LiveTsWebSocketBroker; } catch (error) { console.warn('Rust core not available, using fallback JavaScript implementation'); LiveTSEngine = null; LiveTSWebSocketBroker = null; } class LiveTSServer { constructor(options) { this.components = new Map(); this.activeComponents = new Map(); this.componentHtmlCache = new Map(); // Cache for HTML diffing this.brokerConnComponents = new Map(); this.port = options.port ?? 3000; this.host = options.host ?? 'localhost'; this.app = options.app; // Initialize Rust engine/broker if available if (LiveTSEngine) { this.rustEngine = new LiveTSEngine(); } else { console.log('📝 Using JavaScript fallback for DOM manipulation'); } // Initialize Rust broker if available if (LiveTSWebSocketBroker) { try { this.rustBroker = new LiveTSWebSocketBroker(); this.rustBroker.setEventHandler((...args) => { // Handle broker events from Rust const evtJson = args[1]; this.handleBrokerEvent(evtJson); }); this.setupWebSocketServer(); } catch (error) { console.error('Failed to initialize Rust broker:', error); this.rustBroker = undefined; } } else { console.log('📝 Rust broker not available, will use Node.js WebSocket fallback'); } this.setupMiddleware(); } /** * Registers a LiveView component with a simple path-based route */ registerComponent(path, ComponentClass, options) { this.registerSimpleComponent(path, ComponentClass, options); } registerSimpleComponent(path, ComponentClass, options) { this.components.set(path, ComponentClass); this.app.get(path, async (c) => { try { const props = this.extractPropsFromContext(c); const component = new ComponentClass(props); // Mount the component await component._mount(); // Store active component this.activeComponents.set(component.getComponentId(), component); // Render the component const html = component._render(); // Cache the initial HTML for diffing this.componentHtmlCache.set(component.getComponentId(), html); const fullHtml = this.wrapInLayout(html, options); return c.html(fullHtml); } catch (error) { console.error(`Error rendering component for ${path}:`, error); return c.text('Internal Server Error', 500); } }); } async close() { if (this.rustBroker) { try { this.rustBroker.stop(); } catch { } } // Clean up active components for (const component of this.activeComponents.values()) { await component._unmount(); } this.activeComponents.clear(); this.componentHtmlCache.clear(); } /** * Gets server statistics */ getStats() { return { activeComponents: this.activeComponents.size, registeredRoutes: this.components.size, wsConnections: 0 // TODO: Add connection count method to Rust broker }; } // ===== Private Methods ===== findComponentIdByShortId(shortId) { // Find component by matching first 8 characters of ID for (const [fullId] of this.activeComponents) { if (fullId.substring(0, 8) === shortId) { return fullId; } } return null; } setupMiddleware() { // CORS middleware this.app.use('*', async (c, next) => { c.header('Access-Control-Allow-Origin', '*'); c.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); if (c.req.method === 'OPTIONS') { return c.text('', 204); } await next(); return; }); // LiveTS connector script - serve the built client this.app.get('/livets/connector.js', c => { const connectorScript = this.generateConnectorScript(); c.header('Content-Type', 'application/javascript'); return c.text(connectorScript); }); } setupWebSocketServer() { // If Rust broker is available, start it on a dedicated WebSocket port if (!this.rustBroker) { throw new Error('Rust broker is not available'); } // Use dedicated WebSocket port for optimal performance const wsPort = this.port + 1; this.rustBroker.listen(this.host, wsPort); console.log(`🦀 Rust WebSocket broker listening on ws://${this.host}:${wsPort}/livets-ws`); } async handleBrokerEvent(evtJson) { try { // Handle empty or null JSON strings if (!evtJson || evtJson.trim() === '' || evtJson === 'null') { console.warn('⚠️ Received empty or null event JSON:', evtJson); return; } const evt = JSON.parse(evtJson); // Additional null check after parsing if (!evt || typeof evt !== 'object') { console.warn('⚠️ Parsed event is null or not an object:', evt); return; } if (!evt.type) { console.warn('⚠️ Event missing type property:', evt); return; } switch (evt.type) { case 'Connected': // Optionally handle new connections break; case 'Message': { const { connection_id, data } = evt; // Ultra-fast path for compact formats (no JSON parsing needed) if (data === '"p"') { // Compact ping - just ignore (broker handles internally) return; } else if (data.startsWith('"e|')) { // Compact event format: "e|shortId|eventName|value|checked|tagName" await this.handleCompactEvent(connection_id, data); } else if (data.startsWith('{"type":"event"')) { // Legacy JSON event format (fallback) await this.handleEventMessageDirect(connection_id, data); } else { // Other message types - use JSON parsing const message = JSON.parse(data); await this.handleBrokerMessage(connection_id, message); } break; } case 'Closed': { // Cleanup component registrations for this connection const connection_id = evt.connection_id; const set = this.brokerConnComponents.get(connection_id); if (set && this.rustBroker) { for (const componentId of set) { try { this.rustBroker.unregisterComponent(componentId, connection_id); } catch { } } } this.brokerConnComponents.delete(connection_id); break; } } } catch (e) { console.error('Error in broker event handler:', e); } } async handleCompactEvent(connection_id, data) { try { // Parse compact format: "e|shortId|eventName|value|checked|tagName" // Remove quotes and split by pipes const content = data.slice(1, -1); // Remove surrounding quotes const parts = content.split('|'); if (parts.length < 3 || parts[0] !== 'e') { console.warn('Invalid compact event format:', data); return; } const shortId = parts[1] || ''; const eventName = parts[2] || ''; const value = parts[3] || ''; const checked = parts[4] === '1'; const tagName = parts[5] || ''; // Find full component ID from short ID const componentId = this.findComponentIdByShortId(shortId); if (!componentId) { console.warn(`Component not found for short ID: ${shortId}`); return; } // Reconstruct payload in expected format const payload = { type: 'input', // Default event type target: { tagName: tagName.toLowerCase(), value: value || '', checked: checked, dataset: {} } }; // Track registration let set = this.brokerConnComponents.get(connection_id); if (!set) { set = new Set(); this.brokerConnComponents.set(connection_id, set); } if (!set.has(componentId)) { try { this.rustBroker?.registerComponent(componentId, connection_id); } catch { } set.add(componentId); } const compactMsg = await this.processEventAndGenerateMessage(componentId, eventName, payload); if (!compactMsg) return; try { this.rustBroker?.sendToConnection(connection_id, compactMsg); } catch (e) { console.error('Failed sending patches via broker:', e); } } catch (e) { console.error('Failed parsing compact event:', e, 'data:', data); } } async handleEventMessageDirect(connection_id, data) { try { // Fast parsing for: {"type":"event","componentId":"xxx","eventName":"yyy","payload":{...}} const componentIdMatch = data.match(/"componentId":"([^"]+)"/); const eventNameMatch = data.match(/"eventName":"([^"]+)"/); const payloadMatch = data.match(/"payload":({.*})}/); if (!componentIdMatch || !eventNameMatch) { // Fall back to JSON parsing if fast parsing fails const message = JSON.parse(data); await this.handleClientEventFromBroker(connection_id, message); return; } const componentId = componentIdMatch[1]; const eventName = eventNameMatch[1]; const payload = payloadMatch ? JSON.parse(payloadMatch[1]) : {}; // Track registration to avoid duplicates let set = this.brokerConnComponents.get(connection_id); if (!set) { set = new Set(); this.brokerConnComponents.set(connection_id, set); } if (!set.has(componentId)) { try { this.rustBroker?.registerComponent(componentId, connection_id); } catch { } set.add(componentId); } const compactMsg = await this.processEventAndGenerateMessage(componentId, eventName, payload); if (!compactMsg) return; try { this.rustBroker?.sendToConnection(connection_id, compactMsg); } catch (e) { console.error('Failed sending patches via broker:', e); } } catch (e) { console.error('Failed direct event parsing, falling back to JSON:', e); // Fallback to full JSON parsing const message = JSON.parse(data); await this.handleClientEventFromBroker(connection_id, message); } } async handleBrokerMessage(connection_id, message) { switch (message.type) { case 'event': await this.handleClientEventFromBroker(connection_id, message); break; case 'ping': // The broker responds to ping internally; no-op here break; default: console.warn(`Unknown broker message type: ${message.type}`); } } async handleClientEventFromBroker(connection_id, message) { const { componentId, eventName, payload } = message; // track registration to avoid duplicates let set = this.brokerConnComponents.get(connection_id); if (!set) { set = new Set(); this.brokerConnComponents.set(connection_id, set); } if (!set.has(componentId)) { try { this.rustBroker?.registerComponent(componentId, connection_id); } catch { } set.add(componentId); } const compactMsg = await this.processEventAndGenerateMessage(componentId, eventName, payload); if (!compactMsg) return; try { this.rustBroker?.sendToConnection(connection_id, compactMsg); } catch (e) { console.error('Failed sending patches via broker:', e); } } async processEventAndGenerateMessage(componentId, eventName, payload) { const component = this.activeComponents.get(componentId); if (!component) { console.warn(`Component not found for id: ${componentId}`); return null; } const oldHtml = this.componentHtmlCache.get(componentId) ?? component._render(); try { await Promise.resolve(component.handleEvent(eventName, payload)); } catch (e) { console.error(`Error handling event '${eventName}' for component ${componentId}:`, e); return null; } let newHtml; try { newHtml = component._render(); } catch (e) { console.error(`Error rendering component after event for ${componentId}:`, e); return null; } // Update cache with latest HTML this.componentHtmlCache.set(componentId, newHtml); // Generate complete message using Rust (zero JSON operations) try { if (this.rustEngine && typeof this.rustEngine.renderComponentMessage === 'function') { return this.rustEngine.renderComponentMessage(componentId, oldHtml, newHtml); } } catch (e) { console.warn('Rust engine message generation failed, falling back:', e); } // Fallback: manual string building for full replace const shortId = componentId.substring(0, 8); return `{"t":"p","c":"${shortId}","d":["h|[data-livets-id=\\"${componentId}\\"]|${newHtml.replace(/"/g, '\\"')}"]}`; } async processEventAndDiff(componentId, eventName, payload) { const component = this.activeComponents.get(componentId); if (!component) { console.warn(`Component not found for id: ${componentId}`); return null; } const oldHtml = this.componentHtmlCache.get(componentId) ?? component._render(); try { await Promise.resolve(component.handleEvent(eventName, payload)); } catch (e) { console.error(`Error handling event '${eventName}' for component ${componentId}:`, e); return null; } let newHtml; try { newHtml = component._render(); } catch (e) { console.error(`Error rendering component after event for ${componentId}:`, e); return null; } // Update cache with latest HTML this.componentHtmlCache.set(componentId, newHtml); // Compute patches try { if (this.rustEngine && typeof this.rustEngine.renderComponentCompact === 'function') { const result = this.rustEngine.renderComponentCompact(componentId, oldHtml, newHtml); // The Rust engine now returns a JSON string if (typeof result === 'string') { try { return JSON.parse(result); } catch (parseError) { console.warn('Failed to parse Rust engine result as JSON:', parseError); } } // Already an object/array (for backwards compatibility) if (Array.isArray(result)) return result; } } catch (e) { console.warn('Rust engine diff failed, falling back to full replace:', e); } // Fallback: replace the entire component inner HTML console.log('Falling back to full replace for component:', componentId); return [ { type: 'ReplaceInnerHtml', selector: `[data-livets-id="${componentId}"]`, html: newHtml } ]; } generateConnectorScript() { try { // Try to load the built client connector const clientPath = require.resolve('@magnusbag/livets-client/dist/connector.js'); return (0, fs_1.readFileSync)(clientPath, 'utf-8'); } catch (error) { console.warn('Could not load built client connector, falling back to inline version'); // Fallback to a minimal inline connector if package is not available return this.getFallbackConnectorScript(); } } // Injects page layout with optional WS URL override when using Rust broker wrapInLayout(html, options) { const title = options?.title ?? 'LiveTS App'; const meta = options?.meta ?? {}; const scripts = options?.scripts ?? []; const styles = options?.styles ?? []; const metaTags = Object.entries(meta) .map(([key, value]) => `<meta name="${key}" content="${value}">`) .join('\n '); const styleTags = styles.map(href => `<link rel="stylesheet" href="${href}">`).join('\n '); const scriptTags = scripts.map(src => `<script src="${src}"></script>`).join('\n '); // If broker is active, inject a global override for the client to use const wsOverrideScript = this.rustBroker ? `<script>window.LIVETS_WS_URL = "${this.getBrokerWsUrl()}";</script>` : ''; return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${title}</title> ${metaTags} ${styleTags} </head> <body> <div data-livets-root> ${html} </div> ${wsOverrideScript} <script src="/livets/connector.js"></script> ${scriptTags} </body> </html>`; } getBrokerWsUrl() { const wsPort = this.port + 1; // We default to ws:// for dev server; users can front with TLS if needed const protocol = 'ws://'; const host = this.host; return `${protocol}${host}:${wsPort}`; } getFallbackConnectorScript() { // Construct a small connector that resolves WS URL at runtime return ` console.log('Using fallback connector - please install @magnusbag/livets-client for full functionality'); // Minimal fallback connector class FallbackConnector { constructor() { this.ws = new WebSocket(this.getWsUrl()); this.setupEventListeners(); } getWsUrl() { if (window.LIVETS_WS_URL) return window.LIVETS_WS_URL; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; return protocol + '//' + host + '/livets-ws'; } setupEventListeners() { this.ws.onopen = () => console.log('LiveTS WebSocket connected'); this.ws.onmessage = (event) => { const message = JSON.parse(event.data); if (message.type === 'patches') this.applyPatches(message.patches); }; this.ws.onclose = () => console.log('LiveTS WebSocket disconnected'); this.ws.onerror = (error) => console.error('LiveTS WebSocket error:', error); document.addEventListener('click', this.handleDomEvent.bind(this)); document.addEventListener('input', this.handleDomEvent.bind(this)); document.addEventListener('submit', this.handleDomEvent.bind(this)); document.addEventListener('change', this.handleDomEvent.bind(this)); } handleDomEvent(event) { const element = event.target.closest('[ts-on\\\\:' + event.type + ']'); if (!element) return; const handler = element.getAttribute('ts-on:' + event.type); const componentElement = element.closest('[data-livets-id]'); if (!handler || !componentElement) return; event.preventDefault(); if (this.ws.readyState === WebSocket.OPEN) { // Use compact event format for fallback connector too const componentId = componentElement.dataset.livetsId; const shortId = componentId.substring(0, 8); const value = element.value || ''; const checked = element.checked ? '1' : '0'; const tagName = element.tagName.toLowerCase(); this.ws.send('"e|' + shortId + '|' + handler + '|' + value + '|' + checked + '|' + tagName + '"'); } } applyPatches(patches) { patches.forEach(patch => { try { switch (patch.type) { case 'ReplaceText': case 'UpdateText': const textEl = document.querySelector(patch.selector); if (textEl) textEl.textContent = patch.content || patch.text; break; case 'SetAttribute': const attrEl = document.querySelector(patch.selector); if (attrEl) attrEl.setAttribute(patch.attr, patch.value); break; case 'RemoveAttribute': const removeAttrEl = document.querySelector(patch.selector); if (removeAttrEl) removeAttrEl.removeAttribute(patch.attr); break; case 'ReplaceInnerHtml': const replaceEl = document.querySelector(patch.selector); if (replaceEl) replaceEl.innerHTML = patch.html; break; } } catch (error) { console.error('Error applying patch:', patch, error); } }); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => new FallbackConnector()); } else { new FallbackConnector(); } `; } // Extract props from Hono context (query params only for now) extractPropsFromContext(c) { try { const query = c?.req?.query?.() ?? {}; return { ...query }; } catch { return {}; } } } exports.LiveTSServer = LiveTSServer; //# sourceMappingURL=server.js.map