UNPKG

minilive

Version:

Server-driven UI framework with live updates and seamless state management

604 lines (512 loc) 20.4 kB
// minilive.js - Main entry point for the minilive package const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const path = require('path'); const bodyParser = require('body-parser'); const cookieParser = require('cookie-parser'); const fs = require('fs'); const mustache = require('mustache'); const connectionManager = require('./connection'); class MiniLive { constructor(options = {}) { this.options = { port: options.port || process.env.PORT || 3000, pagesDir: options.pagesDir || path.join(process.cwd(), 'pages'), logicDir: options.logicDir || path.join(process.cwd(), 'logic'), publicDir: options.publicDir || path.join(process.cwd(), 'public'), commandHandler: options.commandHandler || null, templateRewriter: options.templateRewriter || null, includes: options.includes || null, ...options }; this.app = express(); this.server = http.createServer(this.app); this.io = new Server(this.server); this.setupMiddleware(); this.setupRoutes(); this.setupWebSocket(); } setupMiddleware() { // Serve morphdom from this package's node_modules this.app.get('/minilive/morphdom.js', (req, res) => { const morphdomPath = require.resolve('morphdom/dist/morphdom-umd.js'); res.sendFile(morphdomPath); }); // Serve client.js from this package this.app.get('/minilive/client.js', (req, res) => { res.sendFile(path.join(__dirname, 'client.js')); }); // User's public directory if (fs.existsSync(this.options.publicDir)) { this.app.use(express.static(this.options.publicDir)); } this.app.use(bodyParser.json()); this.app.use(cookieParser()); } // Helper: Execute logic script with input async executeLogic(page, input) { const logicPath = path.join(this.options.logicDir, `${page}.js`); try { // Read the script const script = fs.readFileSync(logicPath, 'utf8'); // Create isolated context with input and output const sandbox = { input: input, output: {}, console: console, require: require, global: global, process: process }; // Execute in VM context with async support const vm = require('vm'); vm.createContext(sandbox); // Wrap script in async function to allow await let asyncScript = ` (async function() { ${script} })(); `; // Add prepend and postpend if provided if (this.options.prepend || this.options.postpend) { asyncScript = ` (async function() { ${this.options.prepend || ''} ${script} ${this.options.postpend || ''} })(); `; } const result = vm.runInContext(asyncScript, sandbox); // If the result is a Promise, await it if (result && typeof result.then === 'function') { await result; } return sandbox.output; } catch (err) { console.error(`Error in logic script ${page}.js:`, err); throw err; } } // Process commands async processCommands(commands, socket = null, res = null) { if (!commands || !Array.isArray(commands)) return; for (const cmd of commands) { const cmdType = cmd.type || cmd.command; // First, try custom command handler if provided if (this.options.commandHandler) { const handled = await this.options.commandHandler(cmd, { socket, res }); if (handled) continue; // Skip built-in handling if custom handler returned true } // Built-in command handling switch (cmdType) { case 'redirect': if (res) { // HTTP response available res.redirect(cmd.location); } else if (socket) { // WebSocket connection socket.emit('command', { command: 'redirect', location: cmd.location }); } break; case 'reload': if (socket) { socket.emit('command', { command: 'reload' }); } else if (res) { // For HTTP, redirect to self res.redirect(res.req.originalUrl); } break; default: console.log('Unknown command:', cmdType); } } } // Helper: render a template with data renderTemplate(page, data, injectScripts = true, sessionId = null) { const templatePath = path.join(this.options.pagesDir, `${page}.mhtml`); if (!fs.existsSync(templatePath)) { throw new Error(`Template ${page}.mhtml not found`); } let template = fs.readFileSync(templatePath, 'utf8'); // Apply template rewriter if provided if (this.options.templateRewriter) { template = this.options.templateRewriter(template, { page, data, sessionId }); } // Load partials recursively const partials = {}; const loadedPartials = new Set(); // Prevent infinite recursion const loadPartials = (content, currentPath = '') => { // Find all {{> partial}} references in the content const partialRegex = /\{\{>\s*([^\s}]+)\s*\}\}/g; let match; while ((match = partialRegex.exec(content)) !== null) { const partialName = match[1]; // Skip if already loaded (prevents infinite recursion) if (loadedPartials.has(partialName)) { continue; } // Build the partial path const partialPath = path.join(this.options.pagesDir, `${partialName}.mhtml`); // Check if partial file exists if (!fs.existsSync(partialPath)) { throw new Error(`Partial template '${partialName}' not found at ${partialPath}`); } // Read the partial content const partialContent = fs.readFileSync(partialPath, 'utf8'); // Mark as loaded loadedPartials.add(partialName); // Store the partial partials[partialName] = partialContent; // Recursively load any partials referenced in this partial loadPartials(partialContent, partialName); } }; // Start loading partials from the main template loadPartials(template); // Inject required scripts if not present and injectScripts is true if (injectScripts) { // Check if socket.io script is present if (!template.includes('/socket.io/socket.io.js')) { // First, inject a small inline script that defines triggerEvent immediately const inlineScript = `<script> // Session ID for client-side use window._sessionId = '${sessionId}'; // Define triggerEvent immediately to prevent errors window.triggerEvent = window.triggerEvent || function() { console.log('triggerEvent called before client.js loaded, queueing...'); window._eventQueue = window._eventQueue || []; window._eventQueue.push({ eventType: arguments[0], payload: arguments[1] || {} }); }; </script> `; // Find <head> tag and inject after it const headIndex = template.toLowerCase().indexOf('<head>'); if (headIndex !== -1) { const headEndPos = template.indexOf('>', headIndex) + 1; template = template.slice(0, headEndPos) + '\n' + inlineScript + template.slice(headEndPos); } // Then inject the main scripts before </head> const headEndIndex = template.toLowerCase().indexOf('</head>'); if (headEndIndex !== -1) { let allIncludes = ''; // Add custom includes if provided if (this.options.includes) { const pageIncludes = this.options.includes(page); if (Array.isArray(pageIncludes)) { pageIncludes.forEach(include => { if (typeof include === 'string') { // Auto-detect type by extension if (include.endsWith('.css')) { allIncludes += ` <link rel="stylesheet" href="${include}">\n`; } else if (include.endsWith('.js')) { allIncludes += ` <script src="${include}"></script>\n`; } } else if (typeof include === 'object') { // Explicit type if (include.type === 'css') { allIncludes += ` <link rel="stylesheet" href="${include.src}">\n`; } else if (include.type === 'js') { allIncludes += ` <script src="${include.src}"${include.defer ? ' defer' : ''}${include.async ? ' async' : ''}></script>\n`; } else if (include.type === 'inline-css') { allIncludes += ` <style>${include.content}</style>\n`; } else if (include.type === 'inline-js') { allIncludes += ` <script>${include.content}</script>\n`; } } }); } } // Add MiniLive required scripts const scriptTags = ` <script src="/minilive/morphdom.js"></script>\n <script src="/socket.io/socket.io.js"></script>\n <script src="/minilive/client.js"></script>\n`; template = template.slice(0, headEndIndex) + allIncludes + scriptTags + template.slice(headEndIndex); } } } return mustache.render(template, data, partials); } setupRoutes() { // Page routes this.app.get('/:page', async (req, res, next) => { const page = req.params.page; try { // Check if logic file exists const logicPath = path.join(this.options.logicDir, `${page}.js`); if (!fs.existsSync(logicPath)) { return next(); } // Get or create session ID from cookie let sessionId = req.cookies.sessionId; const isNewSession = !sessionId; if (!sessionId) { sessionId = connectionManager.generateSessionId(); } // Check if we have stored input from a previous load const storedInput = !isNewSession ? connectionManager.getLastInput(sessionId, page) : null; let input; if (storedInput) { // Use stored input for seamless refresh console.log(`Using stored input for session ${sessionId} on page ${page}`); input = { ...storedInput }; // Update request data with current request input.request = { method: req.method, headers: req.headers, query: req.query, params: req.params, url: req.url }; } else { // Prepare fresh input for initial load input = { request: { method: req.method, headers: req.headers, query: req.query, params: req.params, url: req.url }, ...req.query, event: 'onLoad' }; } // Execute logic script const output = await this.executeLogic(page, input); // Process commands if any if (output.commands) { await this.processCommands(output.commands, null, res); // If redirect command was issued, stop here if (output.commands.some(cmd => (cmd.type || cmd.command) === 'redirect')) { return; } } // Extract variables for rendering const variables = { ...output }; delete variables.commands; // Set cookie if it's a new session if (isNewSession) { res.cookie('sessionId', sessionId, { maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days httpOnly: true, sameSite: 'strict' }); console.log('New session created:', sessionId); } else { console.log('Existing session found:', sessionId); } // Store the input for future refreshes connectionManager.updateLastInput(sessionId, page, input); // Render the page const html = this.renderTemplate(page, variables, true, sessionId); res.send(html); } catch (err) { console.error('Page route error:', err); next(err); } }); // Admin endpoint for connection stats this.app.get('/admin/connections', (req, res) => { const stats = connectionManager.getStats(); res.json({ stats, connections: Array.from(connectionManager.connections.entries()).map(([sessionId, conn]) => ({ sessionId, currentPage: conn.currentPage, connected: conn.socket && conn.socket.connected, lastActivity: new Date(conn.lastActivity).toISOString() })) }); }); } extractHeadData(html) { const headData = {}; // Extract title const titleMatch = html.match(/<title[^>]*>(.*?)<\/title>/i); if (titleMatch) { headData.title = titleMatch[1]; } // Extract meta tags const metaTags = {}; const metaRegex = /<meta\s+(?:name|property)=["']([^"']+)["']\s+content=["']([^"']+)["'][^>]*>/gi; let metaMatch; while ((metaMatch = metaRegex.exec(html)) !== null) { metaTags[metaMatch[1]] = metaMatch[2]; } if (Object.keys(metaTags).length > 0) { headData.meta = metaTags; } // Extract style tags const styleRegex = /<style[^>]*>([\s\S]*?)<\/style>/gi; const styles = []; let styleMatch; while ((styleMatch = styleRegex.exec(html)) !== null) { styles.push(styleMatch[1]); } if (styles.length > 0) { headData.styles = styles; } return headData; } setupWebSocket() { this.io.on('connection', (socket) => { console.log('New socket connection:', socket.id); // Extract session ID from cookie via handshake const cookies = socket.handshake.headers.cookie || ''; const sessionMatch = cookies.match(/sessionId=([^;]+)/); const sessionId = sessionMatch ? sessionMatch[1] : null; if (!sessionId) { console.error('No session ID in cookie for socket:', socket.id); socket.emit('error', { message: 'No session found. Please refresh the page.' }); socket.disconnect(true); return; } // Wait for client to send page info socket.on('register', ({ currentPage }) => { console.log('Client registration:', { sessionId, currentPage }); connectionManager.registerConnection(socket, sessionId, currentPage); }); socket.on('event', async ({ page, eventType, payload }) => { try { // Get connection info const connectionInfo = connectionManager.getConnection(sessionId); if (!connectionInfo) { console.error('No connection found for session:', sessionId); socket.emit('error', { message: 'Session not found. Please refresh the page.' }); return; } // Handle SPA navigation let input; if (eventType === 'navigate' && payload.targetPage) { page = payload.targetPage; connectionManager.updateCurrentPage(sessionId, page); // Check if we have stored input for this page (back button case) const storedInput = connectionManager.getLastInput(sessionId, page); if (storedInput) { // Use the stored input to restore exact previous state input = { ...storedInput }; // Update request data with current input.request = { method: 'GET', headers: socket.handshake.headers, query: socket.handshake.query }; } else { // No stored state, treat as fresh load input = { request: { method: 'GET', headers: socket.handshake.headers, query: socket.handshake.query }, ...socket.handshake.query, event: 'onLoad', payload: {} }; } } else { // Regular event, not navigation input = { request: { method: 'GET', headers: socket.handshake.headers, query: socket.handshake.query }, ...socket.handshake.query, event: eventType, payload: payload || {} }; } // Execute the logic script const output = await this.executeLogic(page, input); // Process commands if any if (output.commands) { await this.processCommands(output.commands, socket); if (output.commands.some(cmd => (cmd.type || cmd.command) === 'redirect')) { return; } } // Extract variables const variables = { ...output }; delete variables.commands; // Check for changes const isNavigation = eventType === 'navigate'; const pageStates = connectionManager.getPageStates(sessionId); const prevVariables = pageStates[page] || {}; if (isNavigation || JSON.stringify(prevVariables) !== JSON.stringify(variables)) { connectionManager.updatePageState(sessionId, page, variables); connectionManager.updateLastInput(sessionId, page, input); const htmlNew = this.renderTemplate(page, variables, false); // Extract head metadata const headData = this.extractHeadData(htmlNew); // Extract body content const bodyMatch = htmlNew.match(/<body[^>]*>([\s\S]*)<\/body>/i); const bodyContent = bodyMatch ? bodyMatch[1] : htmlNew; socket.emit('update', { html: bodyContent, head: headData }); } } catch (err) { console.error('Socket event error:', err); socket.emit('error', { message: err.message }); } }); // Handle disconnection socket.on('disconnect', () => { console.log('Socket disconnected:', socket.id); connectionManager.handleDisconnect(socket.id); }); }); } serve(callback) { this.server.listen(this.options.port, '0.0.0.0', () => { console.log(`🚀 MiniLive server listening on http://0.0.0.0:${this.options.port}`); if (callback) callback(); }); return this.server; } } // Standalone testLogic function for testing pages async function testLogic(pageName, jsonInput, options = {}) { const logicDir = options.logicDir || path.join(process.cwd(), 'logic'); const logicPath = path.join(logicDir, `${pageName}.js`); try { // Read the script const script = fs.readFileSync(logicPath, 'utf8'); // Create isolated context with input and output const sandbox = { input: jsonInput, output: {}, console: console, require: require, global: global, process: process }; // Execute in VM context with async support const vm = require('vm'); vm.createContext(sandbox); // Wrap script in async function to allow await const asyncScript = ` (async function() { ${script} })(); `; const result = vm.runInContext(asyncScript, sandbox); // If the result is a Promise, await it if (result && typeof result.then === 'function') { await result; } return sandbox.output; } catch (err) { throw new Error(`Error in logic script ${pageName}.js: ${err.message}`); } } // Export factory function for cleaner API module.exports = function createMiniLive(options) { return new MiniLive(options); }; // Also export the class for advanced usage module.exports.MiniLive = MiniLive; // Export testLogic function module.exports.testLogic = testLogic;