/** * Static file server with streaming, ETags, and directory listing */ import * as plugins from '../plugins.js'; import type { IStaticOptions, IDirectoryListingOptions, IFileEntry, } from '../core/smartserve.interfaces.js'; import { getMimeType } from '../utils/utils.mime.js'; import { generateETag } from '../utils/utils.etag.js'; /** * Static file server */ export class FileServer { private options: IStaticOptions; constructor(options: IStaticOptions | string) { if (typeof options === 'string') { this.options = { root: options }; } else { this.options = options; } // Set defaults this.options.index = this.options.index ?? ['index.html', 'index.htm']; this.options.dotFiles = this.options.dotFiles ?? 'ignore'; this.options.etag = this.options.etag ?? true; this.options.lastModified = this.options.lastModified ?? true; } /** * Handle a request for static files */ async serve(request: Request): Promise { const url = new URL(request.url); let pathname = decodeURIComponent(url.pathname); // Security: prevent path traversal if (pathname.includes('..')) { return new Response('Forbidden', { status: 403 }); } // Resolve file path const filePath = plugins.path.join(this.options.root, pathname); // Check if path is within root const realRoot = plugins.path.resolve(this.options.root); const realPath = plugins.path.resolve(filePath); if (!realPath.startsWith(realRoot)) { return new Response('Forbidden', { status: 403 }); } try { const stat = await plugins.fs.promises.stat(realPath); if (stat.isDirectory()) { // Try index files for (const indexFile of this.options.index!) { const indexPath = plugins.path.join(realPath, indexFile); try { const indexStat = await plugins.fs.promises.stat(indexPath); if (indexStat.isFile()) { return this.serveFile(request, indexPath, indexStat); } } catch { // Index file doesn't exist, continue } } // Directory listing if (this.options.directoryListing) { return this.serveDirectory(request, realPath, pathname); } return new Response('Forbidden', { status: 403 }); } if (stat.isFile()) { // Check dotfile policy const basename = plugins.path.basename(realPath); if (basename.startsWith('.')) { if (this.options.dotFiles === 'deny') { return new Response('Forbidden', { status: 403 }); } if (this.options.dotFiles === 'ignore') { return null; // Let other handlers try } } return this.serveFile(request, realPath, stat); } return null; } catch (err: any) { if (err.code === 'ENOENT') { return null; // File not found, let other handlers try } throw err; } } /** * Serve a single file with proper headers */ private async serveFile( request: Request, filePath: string, stat: plugins.fs.Stats ): Promise { const headers = new Headers(); // Content-Type const mimeType = getMimeType(filePath); headers.set('Content-Type', mimeType); // Content-Length headers.set('Content-Length', stat.size.toString()); // Last-Modified if (this.options.lastModified) { headers.set('Last-Modified', stat.mtime.toUTCString()); } // ETag let etag: string | undefined; if (this.options.etag) { etag = generateETag(stat); headers.set('ETag', etag); } // Cache-Control if (this.options.cacheControl) { const cacheControl = typeof this.options.cacheControl === 'function' ? this.options.cacheControl(filePath) : this.options.cacheControl; headers.set('Cache-Control', cacheControl); } // Check conditional requests const ifNoneMatch = request.headers.get('If-None-Match'); if (etag && ifNoneMatch === etag) { return new Response(null, { status: 304, headers }); } const ifModifiedSince = request.headers.get('If-Modified-Since'); if (ifModifiedSince) { const clientDate = new Date(ifModifiedSince); if (stat.mtime <= clientDate) { return new Response(null, { status: 304, headers }); } } // Handle Range requests const rangeHeader = request.headers.get('Range'); if (rangeHeader) { return this.servePartial(filePath, stat, rangeHeader, headers); } // HEAD request if (request.method === 'HEAD') { return new Response(null, { status: 200, headers }); } // Stream the file const stream = plugins.fs.createReadStream(filePath); const readableStream = this.nodeStreamToWebStream(stream); return new Response(readableStream, { status: 200, headers }); } /** * Serve partial content (Range request) */ private async servePartial( filePath: string, stat: plugins.fs.Stats, rangeHeader: string, headers: Headers ): Promise { const size = stat.size; const match = rangeHeader.match(/bytes=(\d*)-(\d*)/); if (!match) { return new Response('Invalid Range', { status: 416 }); } let start = match[1] ? parseInt(match[1], 10) : 0; let end = match[2] ? parseInt(match[2], 10) : size - 1; // Validate range if (start >= size || end >= size || start > end) { headers.set('Content-Range', `bytes */${size}`); return new Response('Range Not Satisfiable', { status: 416, headers }); } // Set partial content headers headers.set('Content-Range', `bytes ${start}-${end}/${size}`); headers.set('Content-Length', (end - start + 1).toString()); headers.set('Accept-Ranges', 'bytes'); const stream = plugins.fs.createReadStream(filePath, { start, end }); const readableStream = this.nodeStreamToWebStream(stream); return new Response(readableStream, { status: 206, headers }); } /** * Serve directory listing */ private async serveDirectory( request: Request, dirPath: string, urlPath: string ): Promise { const entries = await plugins.fs.promises.readdir(dirPath, { withFileTypes: true }); const files: IFileEntry[] = []; for (const entry of entries) { // Skip hidden files unless configured const listingOptions = typeof this.options.directoryListing === 'object' ? this.options.directoryListing : {}; if (entry.name.startsWith('.') && !listingOptions.showHidden) { continue; } const entryPath = plugins.path.join(dirPath, entry.name); const stat = await plugins.fs.promises.stat(entryPath); files.push({ name: entry.name, path: plugins.path.join(urlPath, entry.name), isDirectory: entry.isDirectory(), size: stat.size, modified: stat.mtime, }); } // Sort files const listingOptions = typeof this.options.directoryListing === 'object' ? this.options.directoryListing : {}; const sortBy = listingOptions.sortBy ?? 'name'; const sortOrder = listingOptions.sortOrder ?? 'asc'; files.sort((a, b) => { // Directories first if (a.isDirectory !== b.isDirectory) { return a.isDirectory ? -1 : 1; } let comparison = 0; switch (sortBy) { case 'size': comparison = a.size - b.size; break; case 'modified': comparison = a.modified.getTime() - b.modified.getTime(); break; default: comparison = a.name.localeCompare(b.name); } return sortOrder === 'desc' ? -comparison : comparison; }); // Custom template if (listingOptions.template) { const result = listingOptions.template(files); if (result instanceof Response) { return result; } return new Response(result, { headers: { 'Content-Type': 'text/html; charset=utf-8' }, }); } // Default HTML listing const html = this.generateDirectoryHtml(urlPath, files); return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' }, }); } /** * Generate default directory listing HTML */ private generateDirectoryHtml(urlPath: string, files: IFileEntry[]): string { const formatSize = (size: number): string => { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`; return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`; }; const formatDate = (date: Date): string => { return date.toISOString().replace('T', ' ').slice(0, 19); }; const escapeHtml = (str: string): string => { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); }; const rows = files.map(file => { const icon = file.isDirectory ? '📁' : '📄'; const href = encodeURIComponent(file.name) + (file.isDirectory ? '/' : ''); const size = file.isDirectory ? '-' : formatSize(file.size); return ` ${icon} ${escapeHtml(file.name)} ${size} ${formatDate(file.modified)} `; }).join('\n'); // Add parent directory link if not at root const parentLink = urlPath !== '/' ? ` 📁 .. - - ` : ''; return ` Index of ${escapeHtml(urlPath)}

Index of ${escapeHtml(urlPath)}

${parentLink} ${rows}
NameSizeModified
`; } /** * Convert Node.js stream to Web ReadableStream */ private nodeStreamToWebStream(nodeStream: plugins.fs.ReadStream): ReadableStream { return new ReadableStream({ start(controller) { nodeStream.on('data', (chunk: Buffer) => { controller.enqueue(new Uint8Array(chunk)); }); nodeStream.on('end', () => { controller.close(); }); nodeStream.on('error', (err) => { controller.error(err); }); }, cancel() { nodeStream.destroy(); }, }); } }