@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
JavaScript
/**
* 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