UNPKG

@fwdslsh/unify

Version:

A lightweight, framework-free static site generator with Bun native APIs

610 lines (538 loc) 19.9 kB
/** * Development server with live reload support * Provides high-performance HTTP serving with live reload */ import path from 'path'; import { logger } from '../utils/logger.js'; import pkg from "../../package.json"; /** * Check if a path looks like a system file that should not trigger SPA fallback */ function looksLikeSystemPath(pathname) { const suspiciousPaths = [ '/etc/', '/var/', '/usr/', '/bin/', '/sbin/', '/root/', '/home/', '/windows/', '/system32/', '/config/', '/temp/', '/tmp/', 'passwd', 'shadow', 'hosts', 'config', 'system32', 'windows' ]; const lowerPath = pathname.toLowerCase(); // Check for suspicious path components if (suspiciousPaths.some(suspicious => lowerPath.includes(suspicious))) { return true; } // Check for specific suspicious patterns but allow legitimate web asset names // Only block paths that look like system files or weird attempts if (pathname.match(/^\/(?:normal-file|test-file|random-path|malicious-file|attack-vector)$/i)) { return true; } return false; } export class DevServer { constructor() { this.server = null; this.isRunning = false; this.config = null; this.sseClients = new Set(); } /** * Start the development server * @param {Object} options - Server configuration options */ async start(options = {}) { const config = { port: 3000, hostname: '127.0.0.1', // Use explicit IPv4 for Windows compatibility outputDir: 'dist', fallback: 'index.html', cors: true, liveReload: true, openBrowser: false, ...options }; this.config = config; try { logger.info(`Starting development server on http://${config.hostname}:${config.port}`); const serverOptions = { port: config.port, hostname: config.hostname, fetch: this.handleRequest.bind(this), error: this.handleError.bind(this), development: true, idleTimeout: 255 // Maximum allowed value (255 seconds = ~4.25 minutes) }; // reusePort is not supported on Windows if (process.platform !== 'win32') { serverOptions.reusePort = true; } this.server = Bun.serve(serverOptions); if (!this.server) { logger.error('Server failed to start.'); throw new Error('Failed to start development server.'); } this.isRunning = true; logger.success(`Development server running at http://${config.hostname}:${config.port}`); // Open browser if requested if (config.openBrowser) { await this.openBrowser(`http://${config.hostname}:${config.port}`); } return this; } catch (error) { logger.error('Failed to start development server:', error?.message || error); throw error; } } /** * Handle HTTP requests with native routing */ async handleRequest(request) { const url = new URL(request.url); const pathname = url.pathname; // Early security checks // Block excessively long paths (8KB limit) if (pathname.length > 8192) { logger.debug(`Excessively long path blocked: ${pathname.length} bytes`); return new Response('URI Too Long', { status: 414 }); } // Block null byte injection if (pathname.includes('\0') || pathname.includes('%00')) { logger.debug(`Null byte injection attempt blocked: ${pathname}`); return new Response('Bad Request', { status: 400 }); } // Block path traversal attempts if (pathname.includes('..') || pathname.includes('%2e%2e') || pathname.includes('%2E%2E')) { logger.debug(`Path traversal attempt blocked: ${pathname}`); return new Response('Forbidden', { status: 403 }); } try { // Handle Server-Sent Events for live reload if (pathname === '/__live-reload') { return this.handleLiveReloadSSE(request); } // Handle API routes (if any) if (pathname.startsWith('/api/')) { return this.handleApiRoute(request, pathname); } // Serve static files return await this.serveStaticFile(pathname); } catch (error) { logger.error(error.formatForCLI ? error.formatForCLI() : `Request error for ${pathname}: ${error.message}`); return new Response('Internal Server Error', { status: 500 }); } } /** * Serve static files from the output directory */ async serveStaticFile(pathname) { const { outputDir, fallback, cors } = this.config; // Normalize path and prevent directory traversal let filePath = pathname === '/' ? '/index.html' : pathname; const resolvedOutputDir = path.resolve(outputDir); try { // If path ends with a slash, always serve directory index if (filePath.endsWith('/')) { const dirIndexPath = path.join(outputDir, filePath, 'index.html'); const resolvedDirIndexPath = path.resolve(dirIndexPath); if (resolvedDirIndexPath.startsWith(resolvedOutputDir)) { const dirIndexFile = Bun.file(resolvedDirIndexPath); if (await dirIndexFile.exists()) { const headers = this.getFileHeaders(resolvedDirIndexPath, cors); if (this.config.liveReload && resolvedDirIndexPath.endsWith('.html')) { const content = await dirIndexFile.text(); const withLiveReload = this.injectLiveReloadScript(content); return new Response(withLiveReload, { headers }); } return new Response(dirIndexFile, { headers }); } } // If not found, fallback if (fallback && !looksLikeSystemPath(filePath)) { const fallbackPath = path.join(outputDir, fallback); const fallbackFile = Bun.file(fallbackPath); if (await fallbackFile.exists()) { const headers = this.getFileHeaders(fallbackPath, cors); if (this.config.liveReload && fallbackPath.endsWith('.html')) { const content = await fallbackFile.text(); const withLiveReload = this.injectLiveReloadScript(content); return new Response(withLiveReload, { headers }); } return new Response(fallbackFile, { headers }); } } return new Response('Not Found', { status: 404 }); } else if (!path.extname(filePath) && !looksLikeSystemPath(filePath)) { // If no trailing slash and no extension, check for exact file first let exactFilePath = filePath + '.html'; const resolvedExactFilePath = path.resolve(path.join(outputDir, exactFilePath)); if (resolvedExactFilePath.startsWith(resolvedOutputDir)) { const exactFile = Bun.file(resolvedExactFilePath); if (await exactFile.exists()) { const headers = this.getFileHeaders(resolvedExactFilePath, cors); if (this.config.liveReload && resolvedExactFilePath.endsWith('.html')) { const content = await exactFile.text(); const withLiveReload = this.injectLiveReloadScript(content); return new Response(withLiveReload, { headers }); } return new Response(exactFile, { headers }); } } // If not found, check for directory index const dirIndexPath = path.join(outputDir, filePath, 'index.html'); const resolvedDirIndexPath = path.resolve(dirIndexPath); if (resolvedDirIndexPath.startsWith(resolvedOutputDir)) { const dirIndexFile = Bun.file(resolvedDirIndexPath); if (await dirIndexFile.exists()) { const headers = this.getFileHeaders(resolvedDirIndexPath, cors); if (this.config.liveReload && resolvedDirIndexPath.endsWith('.html')) { const content = await dirIndexFile.text(); const withLiveReload = this.injectLiveReloadScript(content); return new Response(withLiveReload, { headers }); } return new Response(dirIndexFile, { headers }); } } } else { // Otherwise, treat as a direct file request const resolvedPath = path.resolve(path.join(outputDir, filePath)); if (resolvedPath.startsWith(resolvedOutputDir)) { const file = Bun.file(resolvedPath); if (await file.exists()) { const headers = this.getFileHeaders(resolvedPath, cors); if (this.config.liveReload && resolvedPath.endsWith('.html')) { const content = await file.text(); const withLiveReload = this.injectLiveReloadScript(content); return new Response(withLiveReload, { headers }); } return new Response(file, { headers }); } } } // Fallback to fallback file (usually index.html for SPAs) // Only fallback for SPA routes: no extension, not system path if (fallback && !path.extname(pathname) && !looksLikeSystemPath(pathname)) { const fallbackPath = path.join(outputDir, fallback); const fallbackFile = Bun.file(fallbackPath); if (await fallbackFile.exists()) { const headers = this.getFileHeaders(fallbackPath, cors); if (this.config.liveReload && fallbackPath.endsWith('.html')) { const content = await fallbackFile.text(); const withLiveReload = this.injectLiveReloadScript(content); return new Response(withLiveReload, { headers }); } return new Response(fallbackFile, { headers }); } } return new Response('Not Found', { status: 404 }); } catch (error) { logger.error(error.formatForCLI ? error.formatForCLI() : `Error serving static file: ${error.message}`); return new Response('Internal Server Error', { status: 500 }); } } /** * Get appropriate headers for file type */ getFileHeaders(filePath, cors = false) { const headers = new Headers(); // Use native mime type detection let mimeType = 'application/octet-stream'; if (typeof Bun !== 'undefined' && Bun.file) { try { const file = Bun.file(filePath); if (file) { mimeType = file.type || mimeType; } } catch {} } headers.set('Content-Type', mimeType); // CORS headers if enabled if (cors) { headers.set('Access-Control-Allow-Origin', '*'); headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); } // Cache control for development headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); // Security headers headers.set('X-Content-Type-Options', 'nosniff'); return headers; } /** * Handle Server-Sent Events for live reload */ handleLiveReloadSSE(request) { const self = this; // Create a simple readable stream for SSE const stream = new ReadableStream({ start(controller) { const client = { id: Math.random().toString(36).substr(2, 9), controller, connected: Date.now(), active: true }; self.sseClients.add(client); // Send initial connection message const connectionMessage = `data: ${JSON.stringify({ type: "connected", clientId: client.id, timestamp: Date.now() })}\n\n`; try { controller.enqueue(new TextEncoder().encode(connectionMessage)); } catch (error) { self.sseClients.delete(client); return; } // Send periodic heartbeat to keep connection alive (every 25 seconds) const heartbeatInterval = setInterval(() => { if (!client.active) { clearInterval(heartbeatInterval); return; } try { const heartbeat = `data: ${JSON.stringify({ type: "heartbeat", timestamp: Date.now() })}\n\n`; controller.enqueue(new TextEncoder().encode(heartbeat)); } catch (error) { client.active = false; clearInterval(heartbeatInterval); self.sseClients.delete(client); } }, 25000); // 25 seconds // Handle cleanup const cleanup = () => { client.active = false; clearInterval(heartbeatInterval); self.sseClients.delete(client); try { if (!controller.closed) { controller.close(); } } catch (error) { // Ignore close errors } }; // Store cleanup on client client.cleanup = cleanup; client.heartbeatInterval = heartbeatInterval; // Handle abort signal request.signal?.addEventListener('abort', cleanup); }, cancel() { // Find and cleanup the client when stream is cancelled for (const client of self.sseClients) { if (client.controller === this.controller) { if (client.cleanup) { client.cleanup(); } break; } } } }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Cache-Control', 'X-Accel-Buffering': 'no' } }); } /** * Handle API routes (extensible for future API needs) */ async handleApiRoute(request, pathname) { const method = request.method; // Basic API info endpoint if (pathname === '/api/info' && method === 'GET') { return new Response(JSON.stringify({ server: 'Unify Dev Server', version: pkg.version, config: { port: this.config.port, outputDir: this.config.outputDir, liveReload: this.config.liveReload } }), { headers: { 'Content-Type': 'application/json' } }); } return new Response('API endpoint not found', { status: 404 }); } /** * Inject live reload script into HTML content */ injectLiveReloadScript(htmlContent) { const liveReloadScript = ` <script> (function() { let reconnectAttempts = 0; const maxReconnectAttempts = 3; // Reduced from 5 let eventSource = null; let lastHeartbeat = Date.now(); function connectToLiveReload() { if (reconnectAttempts >= maxReconnectAttempts) { console.log('Live reload: Max reconnection attempts reached, giving up'); return; } // Close existing connection if any if (eventSource) { eventSource.close(); } eventSource = new EventSource('/__live-reload'); eventSource.onopen = function() { console.log('Live reload: Connected'); reconnectAttempts = 0; // Reset on successful connection lastHeartbeat = Date.now(); }; eventSource.onmessage = function(event) { try { const data = JSON.parse(event.data); if (data.type === 'reload') { console.log('Live reload: Reloading page...'); window.location.reload(); } else if (data.type === 'connected') { console.log('Live reload: Server connection established'); } else if (data.type === 'heartbeat') { lastHeartbeat = Date.now(); // Don't log heartbeats to reduce console noise } } catch (e) { console.warn('Live reload: Invalid message format'); } }; eventSource.onerror = function(event) { console.log('Live reload: Connection lost'); eventSource.close(); // Check if we haven't received a heartbeat in a while (more than 35 seconds) const timeSinceHeartbeat = Date.now() - lastHeartbeat; if (timeSinceHeartbeat > 35000) { console.log('Live reload: Heartbeat timeout, attempting reconnect'); } reconnectAttempts++; if (reconnectAttempts <= maxReconnectAttempts) { const delay = Math.min(2000 * reconnectAttempts, 8000); // Linear backoff, max 8s console.log(\`Live reload: Retrying in \${delay}ms (attempt \${reconnectAttempts}/\${maxReconnectAttempts})\`); setTimeout(connectToLiveReload, delay); } }; } // Start connection connectToLiveReload(); // Cleanup on page unload window.addEventListener('beforeunload', function() { if (eventSource) { eventSource.close(); } }); })(); </script>`; // Inject before closing body tag, or at the end if no body tag if (htmlContent.includes('</body>')) { return htmlContent.replace('</body>', `${liveReloadScript}\n</body>`); } else { return htmlContent + liveReloadScript; } } /** * Broadcast reload event to all connected SSE clients */ broadcastReload() { if (!this.config.liveReload) return; const message = `data: ${JSON.stringify({ type: 'reload', timestamp: Date.now() })}\n\n`; const data = new TextEncoder().encode(message); const disconnectedClients = new Set(); for (const client of this.sseClients) { if (!client.active) { disconnectedClients.add(client); continue; } try { client.controller.enqueue(data); logger.debug(`Sent reload message to client ${client.id}`); } catch (error) { // Mark client for removal client.active = false; disconnectedClients.add(client); logger.debug(`Failed to send reload to client ${client.id}: ${error.message}`); } } // Remove disconnected clients for (const client of disconnectedClients) { if (client.cleanup) { client.cleanup(); } this.sseClients.delete(client); } const activeClients = this.sseClients.size; if (activeClients > 0) { logger.debug(`Live reload broadcasted to ${activeClients} clients`); } } /** * Handle server errors */ handleError(error) { logger.error(error.formatForCLI ? error.formatForCLI() : `Server error: ${error.message}`); return new Response('Server Error', { status: 500 }); } /** * Open browser to the server URL using subprocess */ async openBrowser(url) { try { // Use native subprocess API const proc = Bun.spawn(['open', url], { stdio: ['ignore', 'ignore', 'ignore'], detached: true }); // Don't wait for the process to complete proc.unref(); logger.info(`Opened browser to ${url}`); } catch (error) { logger.warn('Could not open browser:', error.message); } } /** * Stop the development server */ async stop() { if (!this.isRunning) return; try { // Close all SSE connections for (const client of this.sseClients) { try { if (client.cleanup) { client.cleanup(); } else if (client.controller) { client.controller.close(); } } catch (error) { // Ignore close errors } } this.sseClients.clear(); // Stop the server if (this.server) { this.server.stop(); } this.isRunning = false; logger.info('Development server stopped'); } catch (error) { logger.error(error.formatForCLI ? error.formatForCLI() : `Error stopping server: ${error.message}`); } } /** * Get server status information */ getStatus() { return { isRunning: this.isRunning, config: this.config, connectedClients: this.sseClients.size }; } }