UNPKG

@udarapremadasa/iwa-web-server

Version:

A TypeScript library for creating web servers compatible with IWA (Isolated Web Apps) using the Direct Sockets API

282 lines (280 loc) 9.51 kB
/** * StaticFileHandler - Serve static files * * Handles serving static files from the filesystem with proper MIME types */ import { MimeTypes } from '../utils/MimeTypes.js'; import { Logger } from '../utils/Logger.js'; export class StaticFileHandler { constructor(staticDir = '/static', options = {}) { this.staticDir = staticDir; this.options = options; this.logger = new Logger(options.logLevel || 'info'); this.cache = new Map(); // Simple in-memory cache this.maxCacheSize = options.maxCacheSize || 50; // Max files to cache this.maxFileSize = options.maxFileSize || 1024 * 1024; // 1MB max cache file size this.enableCache = options.enableCache !== false; } /** * Serve static file */ async serve(filePath, response) { try { // Normalize path and prevent directory traversal const normalizedPath = this.normalizePath(filePath); if (!normalizedPath) { return await response.error(403, 'Forbidden'); } // Check cache first if (this.enableCache) { const cached = this.cache.get(normalizedPath); if (cached) { this.logger.debug(`Serving cached file: ${normalizedPath}`); return await this.sendCachedFile(cached, response); } } // Try to load file const fileContent = await this.loadFile(normalizedPath); if (!fileContent) { return await response.error(404, 'File Not Found'); } // Cache file if enabled and small enough if (this.enableCache && fileContent.size <= this.maxFileSize) { this.cacheFile(normalizedPath, fileContent); } // Send file await this.sendFile(normalizedPath, fileContent, response); } catch (error) { this.logger.error('Static file serving error:', error); if (!response.isSent()) { await response.error(500, 'Internal Server Error'); } } } /** * Normalize file path and prevent directory traversal */ normalizePath(filePath) { // Remove leading slash if present let path = filePath.startsWith('/') ? filePath.substring(1) : filePath; // Decode URL encoding try { path = decodeURIComponent(path); } catch (error) { this.logger.warn('Invalid URL encoding in path:', filePath); return null; } // Check for directory traversal attempts if (path.includes('..') || path.includes('\\')) { this.logger.warn('Directory traversal attempt:', filePath); return null; } // Default to index.html for directory requests if (path === '' || path.endsWith('/')) { path += 'index.html'; } return path; } /** * Load file from filesystem * In an IWA environment, we need to bundle files or use fetch to load them */ async loadFile(filePath) { try { // Map file paths to actual file locations const baseUrl = window.location.origin + '/'; let actualPath; // Determine the actual path based on the static directory configuration if (this.staticDir === '/public/html') { actualPath = baseUrl + 'html/' + filePath; } else if (this.staticDir === '/static') { actualPath = baseUrl + 'static/' + filePath; } else { // Default path mapping actualPath = baseUrl + filePath; } this.logger.debug(`Loading file from: ${actualPath}`); // Use fetch to load the file const response = await fetch(actualPath); if (!response.ok) { if (response.status === 404) { this.logger.debug(`File not found: ${actualPath}`); return null; } throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const content = await response.text(); return { content: content, size: content.length }; } catch (error) { this.logger.error(`Error loading file ${filePath}:`, error); // Fallback to mock files for demo purposes return this.getMockFile(filePath); } } /** * Get mock file content (fallback for demo) */ getMockFile(filePath) { const mockFiles = { 'index.html': { content: `<!DOCTYPE html> <html> <head> <title>IWA Web Server</title> <style> body { font-family: Arial, sans-serif; margin: 40px; } .container { max-width: 600px; margin: 0 auto; } h1 { color: #333; } .status { background: #e8f5e8; padding: 10px; border-radius: 5px; } </style> </head> <body> <div class="container"> <h1>IWA Web Server</h1> <div class="status"> <p>✅ Server is running successfully!</p> <p>This is a static HTML file served by the IWA Web Server.</p> </div> <h2>Features:</h2> <ul> <li>HTTP request handling</li> <li>Static file serving</li> <li>WebSocket support</li> <li>Routing system</li> <li>Middleware support</li> </ul> </div> </body> </html>`, size: 0 }, 'test.txt': { content: 'Hello from IWA Web Server!', size: 26 }, 'style.css': { content: `body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; } .container { max-width: 800px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } h1 { color: #333; text-align: center; }`, size: 0 } }; const file = mockFiles[filePath]; if (file) { file.size = file.content.length; return file; } return null; } /** * Send file to client */ async sendFile(filePath, fileContent, response) { const mimeType = MimeTypes.getContentType(filePath); // Set appropriate headers response.setHeader('Content-Type', mimeType); response.setHeader('Content-Length', fileContent.size.toString()); response.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache response.setHeader('ETag', `"${this.generateETag(filePath, fileContent)}"`); // Send file content await response.send(fileContent.content); this.logger.debug(`Served file: ${filePath} (${fileContent.size} bytes, ${mimeType})`); } /** * Send cached file */ async sendCachedFile(cached, response) { response.setHeader('Content-Type', cached.mimeType); response.setHeader('Content-Length', cached.size.toString()); response.setHeader('Cache-Control', 'public, max-age=3600'); response.setHeader('ETag', `"${cached.etag}"`); response.setHeader('X-Cache', 'HIT'); await response.send(cached.content); } /** * Cache file in memory */ cacheFile(filePath, fileContent) { // Remove oldest file if cache is full if (this.cache.size >= this.maxCacheSize) { const oldestKey = this.cache.keys().next().value; this.cache.delete(oldestKey); } const cached = { content: fileContent.content, size: fileContent.size, mimeType: MimeTypes.getContentType(filePath), etag: this.generateETag(filePath, fileContent), timestamp: Date.now() }; this.cache.set(filePath, cached); this.logger.debug(`Cached file: ${filePath} (${fileContent.size} bytes)`); } /** * Generate ETag for file */ generateETag(filePath, fileContent) { // Simple ETag based on file path and size const data = filePath + fileContent.size + fileContent.content.length; return btoa(data).substring(0, 16); } /** * Clear cache */ clearCache() { this.cache.clear(); this.logger.info('Static file cache cleared'); } /** * Get cache statistics */ getCacheStats() { const cacheArray = Array.from(this.cache.values()); const totalSize = cacheArray.reduce((sum, item) => sum + item.size, 0); return { files: this.cache.size, maxFiles: this.maxCacheSize, totalSize, enabled: this.enableCache }; } /** * Set cache options */ setCacheOptions(options) { if (options.maxCacheSize !== undefined) { this.maxCacheSize = options.maxCacheSize; } if (options.maxFileSize !== undefined) { this.maxFileSize = options.maxFileSize; } if (options.enableCache !== undefined) { this.enableCache = options.enableCache; } } } export default StaticFileHandler; //# sourceMappingURL=StaticFileHandler.js.map