UNPKG

litenode

Version:

Lightweight and modular web framework

189 lines (163 loc) 7.44 kB
import { readdirSync, statSync, readFileSync, watch } from "node:fs" import { extname, join, sep } from "node:path" import { getContentType } from "../utils/getContentType.js" const NO_STATIC_DIR = "__NO_STATIC_DIR__" export class StaticAssetLoader { constructor(directory) { this.directory = directory this.watchedFiles = [] this.contentTypeCache = new Map() this.fileCache = new Map() } getFiles(dirName, maxDepth = Infinity, depth = 0) { let files = [] if (depth > maxDepth) return files try { const items = readdirSync(dirName, { withFileTypes: true, }) for (const item of items) { const filePath = join(dirName, item.name) if (item.isDirectory()) { files = [...files, ...this.getFiles(filePath, maxDepth, depth + 1)] } else { files.push(filePath) if (!this.watchedFiles.includes(filePath)) { this.watchedFiles.push(filePath) } } } } catch (error) { console.error(`[StaticAssetLoader] Error scanning directory ${dirName}:`, error) } return files } watchDirectory(router) { try { watch( this.directory, { recursive: true, }, (eventType, filename) => { if (!filename) return const filePath = join(this.directory, filename) const fullPath = join(process.cwd(), filePath) // Add this line if (eventType === "rename") { try { // Check if file exists statSync(filePath) // File exists - add route if needed if (!this.watchedFiles.includes(filePath)) { this.addRouteForFile(router, filePath) this.watchedFiles.push(filePath) } } catch (err) { // File was deleted - remove from watched files const index = this.watchedFiles.indexOf(filePath) if (index > -1) { this.watchedFiles.splice(index, 1) } // Clear from cache if it exists if (this.fileCache.has(filePath)) { this.fileCache.delete(filePath) } } } else if (eventType === "change") { // Clear cache on file change to ensure fresh content if (this.fileCache.has(fullPath)) { this.fileCache.delete(fullPath) } } } ) } catch (error) { console.error(`[StaticAssetLoader] Error setting up directory watch:`, error) } } serveFile(fullPath) { return (req, res) => { try { const stats = statSync(fullPath) if (stats.isFile()) { // Create an ETag based on file size and modification time const etag = `W/"${stats.size}-${stats.mtime.getTime()}"` res.setHeader("ETag", etag) // Check if client has a valid cached version const ifNoneMatch = req.headers["if-none-match"] if (ifNoneMatch === etag) { // Client has current version res.statusCode = 304 // Not Modified res.end() return } // Get content type from cache or compute it const ext = extname(fullPath) let contentType = this.contentTypeCache.get(ext) if (!contentType) { contentType = getContentType(ext) this.contentTypeCache.set(ext, contentType) } res.setHeader("Content-Type", contentType) // Set Cache-Control headers based on environment if (process.env.NODE_ENV !== "production") { // In development, use validation caching instead of time-based res.setHeader("Cache-Control", "no-cache") } else if ( /\.(avif|jpg|jpeg|png|gif|webp|svg|ico|css|js|mjs|woff|woff2|ttf|eot)$/i.test(fullPath) ) { res.setHeader("Cache-Control", "public, max-age=86400") // 1 day } else { res.setHeader("Cache-Control", "no-cache") // Don't cache other content } // Get file contents from cache or read from disk let fileContents const isDev = process.env.NODE_ENV !== "production" if (isDev && this.fileCache.has(fullPath)) { fileContents = this.fileCache.get(fullPath) } else { fileContents = readFileSync(fullPath) // Only cache in development mode if (isDev) { this.fileCache.set(fullPath, fileContents) } } res.end(fileContents) } else { res.writeHead(404) res.end("Not Found") } } catch (err) { console.error(`[StaticAssetLoader] Error serving file ${fullPath}: ${err.message}`) if (!res.headersSent) { res.writeHead(500) res.end("Internal Server Error") } } } } addRouteForFile(router, filePath) { // Normalize path separators and ensure proper format const routePath = `/${filePath.split(sep).join("/")}`.replace(/\/+/g, "/") const fullPath = join(process.cwd(), filePath) // Add the route router.get(routePath, this.serveFile(fullPath)) } serveStaticAssets(router) { if (this.directory === NO_STATIC_DIR) { return } try { const staticAssets = this.getFiles(this.directory, 10) // Allowed depth for directory traversal is 10 staticAssets.forEach((el) => { this.addRouteForFile(router, el) }) this.watchDirectory(router) } catch (error) { console.warn( `[StaticAssetLoader] Error while reading static directory: "${this.directory}" directory doesn't exist!` ) console.warn("[StaticAssetLoader] LiteNode will continue running without serving static assets.") } } }