kempo-server 1.7.12 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- export default{allowedMimes:{html:"text/html",htm:"text/html",shtml:"text/html",css:"text/css",xml:"text/xml",gif:"image/gif",jpeg:"image/jpeg",jpg:"image/jpeg",js:"application/javascript",mjs:"application/javascript",json:"application/json",webp:"image/webp",png:"image/png",svg:"image/svg+xml",svgz:"image/svg+xml",ico:"image/x-icon",webm:"video/webm",mp4:"video/mp4",m4v:"video/mp4",ogv:"video/ogg",mp3:"audio/mpeg",ogg:"audio/ogg",wav:"audio/wav",woff:"font/woff",woff2:"font/woff2",ttf:"font/ttf",otf:"font/otf",eot:"application/vnd.ms-fontobject",pdf:"application/pdf",txt:"text/plain",webmanifest:"application/manifest+json",md:"text/markdown",csv:"text/csv",doc:"application/msword",docx:"application/vnd.openxmlformats-officedocument.wordprocessingml.document",xls:"application/vnd.ms-excel",xlsx:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",ppt:"application/vnd.ms-powerpoint",pptx:"application/vnd.openxmlformats-officedocument.presentationml.presentation",avif:"image/avif",wasm:"application/wasm"},disallowedRegex:["^/\\..*","\\.config$","\\.env$","\\.git/","\\.htaccess$","\\.htpasswd$","^/node_modules/","^/vendor/","\\.log$","\\.bak$","\\.sql$","\\.ini$","password","config\\.php$","wp-config\\.php$","\\.DS_Store$"],routeFiles:["GET.js","POST.js","PUT.js","DELETE.js","HEAD.js","OPTIONS.js","PATCH.js","CONNECT.js","TRACE.js","index.js"],noRescanPaths:["^\\.well-known/","/favicon\\.ico$","/robots\\.txt$","/sitemap\\.xml$","/apple-touch-icon","/android-chrome-","/browserconfig\\.xml$","/manifest\\.json$","\\.map$","/__webpack_hmr$","/hot-update\\.","/sockjs-node/"],maxRescanAttempts:3,customRoutes:{},middleware:{cors:{enabled:!1,origin:"*",methods:["GET","POST","PUT","DELETE","OPTIONS"],headers:["Content-Type","Authorization"]},compression:{enabled:!1,threshold:1024},rateLimit:{enabled:!1,maxRequests:100,windowMs:6e4,message:"Too many requests"},security:{enabled:!0,headers:{"X-Content-Type-Options":"nosniff","X-Frame-Options":"DENY","X-XSS-Protection":"1; mode=block"}},logging:{enabled:!0,includeUserAgent:!1,includeResponseTime:!0},custom:[]},cache:{enabled:!1,maxSize:100,maxMemoryMB:50,ttlMs:3e5,maxHeapUsagePercent:70,memoryCheckInterval:3e4,watchFiles:!0,enableMemoryMonitoring:!0}};
1
+ export default{allowedMimes:{html:{mime:"text/html",encoding:"utf8"},htm:{mime:"text/html",encoding:"utf8"},shtml:{mime:"text/html",encoding:"utf8"},css:{mime:"text/css",encoding:"utf8"},xml:{mime:"text/xml",encoding:"utf8"},gif:{mime:"image/gif",encoding:"binary"},jpeg:{mime:"image/jpeg",encoding:"binary"},jpg:{mime:"image/jpeg",encoding:"binary"},js:{mime:"application/javascript",encoding:"utf8"},mjs:{mime:"application/javascript",encoding:"utf8"},json:{mime:"application/json",encoding:"utf8"},webp:{mime:"image/webp",encoding:"binary"},png:{mime:"image/png",encoding:"binary"},svg:{mime:"image/svg+xml",encoding:"utf8"},svgz:{mime:"image/svg+xml",encoding:"utf8"},ico:{mime:"image/x-icon",encoding:"binary"},webm:{mime:"video/webm",encoding:"binary"},mp4:{mime:"video/mp4",encoding:"binary"},m4v:{mime:"video/mp4",encoding:"binary"},ogv:{mime:"video/ogg",encoding:"binary"},mp3:{mime:"audio/mpeg",encoding:"binary"},ogg:{mime:"audio/ogg",encoding:"binary"},wav:{mime:"audio/wav",encoding:"binary"},woff:{mime:"font/woff",encoding:"binary"},woff2:{mime:"font/woff2",encoding:"binary"},ttf:{mime:"font/ttf",encoding:"binary"},otf:{mime:"font/otf",encoding:"binary"},eot:{mime:"application/vnd.ms-fontobject",encoding:"binary"},pdf:{mime:"application/pdf",encoding:"binary"},txt:{mime:"text/plain",encoding:"utf8"},webmanifest:{mime:"application/manifest+json",encoding:"utf8"},md:{mime:"text/markdown",encoding:"utf8"},csv:{mime:"text/csv",encoding:"utf8"},doc:{mime:"application/msword",encoding:"binary"},docx:{mime:"application/vnd.openxmlformats-officedocument.wordprocessingml.document",encoding:"binary"},xls:{mime:"application/vnd.ms-excel",encoding:"binary"},xlsx:{mime:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",encoding:"binary"},ppt:{mime:"application/vnd.ms-powerpoint",encoding:"binary"},pptx:{mime:"application/vnd.openxmlformats-officedocument.presentationml.presentation",encoding:"binary"},avif:{mime:"image/avif",encoding:"binary"},wasm:{mime:"application/wasm",encoding:"binary"}},disallowedRegex:["^/\\..*","\\.config$","\\.env$","\\.git/","\\.htaccess$","\\.htpasswd$","^/node_modules/","^/vendor/","\\.log$","\\.bak$","\\.sql$","\\.ini$","password","config\\.php$","wp-config\\.php$","\\.DS_Store$"],routeFiles:["GET.js","POST.js","PUT.js","DELETE.js","HEAD.js","OPTIONS.js","PATCH.js","CONNECT.js","TRACE.js","index.js"],noRescanPaths:["^\\.well-known/","/favicon\\.ico$","/robots\\.txt$","/sitemap\\.xml$","/apple-touch-icon","/android-chrome-","/browserconfig\\.xml$","/manifest\\.json$","\\.map$","/__webpack_hmr$","/hot-update\\.","/sockjs-node/"],maxRescanAttempts:3,customRoutes:{},middleware:{cors:{enabled:!1,origin:"*",methods:["GET","POST","PUT","DELETE","OPTIONS"],headers:["Content-Type","Authorization"]},compression:{enabled:!1,threshold:1024},rateLimit:{enabled:!1,maxRequests:100,windowMs:6e4,message:"Too many requests"},security:{enabled:!0,headers:{"X-Content-Type-Options":"nosniff","X-Frame-Options":"DENY","X-XSS-Protection":"1; mode=block"}},logging:{enabled:!0,includeUserAgent:!1,includeResponseTime:!0},custom:[]},cache:{enabled:!1,maxSize:100,maxMemoryMB:50,ttlMs:3e5,maxHeapUsagePercent:70,memoryCheckInterval:3e4,watchFiles:!0,enableMemoryMonitoring:!0}};
package/dist/router.js CHANGED
@@ -1 +1 @@
1
- import path from"path";import{readFile}from"fs/promises";import{pathToFileURL}from"url";import defaultConfig from"./defaultConfig.js";import getFiles from"./getFiles.js";import findFile from"./findFile.js";import serveFile from"./serveFile.js";import MiddlewareRunner from"./middlewareRunner.js";import ModuleCache from"./moduleCache.js";import{corsMiddleware,compressionMiddleware,rateLimitMiddleware,securityMiddleware,loggingMiddleware}from"./builtinMiddleware.js";export default async(flags,log)=>{log("Initializing router",3);const rootPath=path.isAbsolute(flags.root)?flags.root:path.join(process.cwd(),flags.root);log(`Root path: ${rootPath}`,3);let config=defaultConfig;try{const configFileName=flags.config||".config.json",configPath=path.isAbsolute(configFileName)?configFileName:path.join(rootPath,configFileName);if(log(`Config file name: ${configFileName}`,3),log(`Config path: ${configPath}`,3),path.isAbsolute(configFileName))log("Config file name is absolute, skipping validation",4);else{const relativeConfigPath=path.relative(rootPath,configPath);if(log(`Relative config path: ${relativeConfigPath}`,4),log(`Starts with '..': ${relativeConfigPath.startsWith("..")}`,4),relativeConfigPath.startsWith("..")||path.isAbsolute(relativeConfigPath))throw log("Validation failed - throwing error",4),new Error(`Config file must be within the server root directory. Config path: ${configPath}, Root path: ${rootPath}`);log("Validation passed",4)}log(`Loading config from: ${configPath}`,3);const configContent=await readFile(configPath,"utf8"),userConfig=JSON.parse(configContent);config={...defaultConfig,...userConfig,allowedMimes:{...defaultConfig.allowedMimes,...userConfig.allowedMimes},middleware:{...defaultConfig.middleware,...userConfig.middleware},customRoutes:{...defaultConfig.customRoutes,...userConfig.customRoutes},cache:{...defaultConfig.cache,...userConfig.cache}},log("User config loaded and merged with defaults",3)}catch(e){if(e.message.includes("Config file must be within the server root directory"))throw e;log("Using default config (no config file found)",3)}const dis=new Set(config.disallowedRegex);dis.add("^/\\..*"),dis.add("\\.config$"),dis.add("\\.git/"),config.disallowedRegex=[...dis],log(`Config loaded with ${config.disallowedRegex.length} disallowed patterns`,3);let files=await getFiles(rootPath,config,log);log(`Initial scan found ${files.length} files`,2);const middlewareRunner=new MiddlewareRunner;if(config.middleware?.cors?.enabled&&(middlewareRunner.use(corsMiddleware(config.middleware.cors)),log("CORS middleware enabled",3)),config.middleware?.compression?.enabled&&(middlewareRunner.use(compressionMiddleware(config.middleware.compression)),log("Compression middleware enabled",3)),config.middleware?.rateLimit?.enabled&&(middlewareRunner.use(rateLimitMiddleware(config.middleware.rateLimit)),log("Rate limit middleware enabled",3)),config.middleware?.security?.enabled&&(middlewareRunner.use(securityMiddleware(config.middleware.security)),log("Security middleware enabled",3)),config.middleware?.logging?.enabled&&(middlewareRunner.use(loggingMiddleware(config.middleware.logging,log)),log("Logging middleware enabled",3)),config.middleware?.custom&&config.middleware.custom.length>0){log(`Loading ${config.middleware.custom.length} custom middleware files`,3);for(const middlewarePath of config.middleware.custom)try{const resolvedPath=path.resolve(middlewarePath),customMiddleware=(await import(pathToFileURL(resolvedPath))).default;"function"==typeof customMiddleware?(middlewareRunner.use(customMiddleware(config.middleware)),log(`Custom middleware loaded: ${middlewarePath}`,3)):log(`Custom middleware error: ${middlewarePath} does not export a default function`,1)}catch(error){log(`Custom middleware error for ${middlewarePath}: ${error.message}`,1)}}let moduleCache=null;config.cache?.enabled&&(moduleCache=new ModuleCache(config.cache),log(`Module cache initialized: ${config.cache.maxSize} max modules, ${config.cache.maxMemoryMB}MB limit, ${config.cache.ttlMs}ms TTL`,2));const customRoutes=new Map,wildcardRoutes=new Map;if(config.customRoutes&&Object.keys(config.customRoutes).length>0){log(`Processing ${Object.keys(config.customRoutes).length} custom routes`,3);for(const[urlPath,filePath]of Object.entries(config.customRoutes))if(urlPath.includes("*")){const resolvedPath=path.isAbsolute(filePath)?filePath:path.resolve(rootPath,filePath);wildcardRoutes.set(urlPath,resolvedPath),log(`Wildcard route mapped: ${urlPath} -> ${resolvedPath}`,3)}else{const resolvedPath=path.isAbsolute(filePath)?filePath:path.resolve(rootPath,filePath);customRoutes.set(urlPath,resolvedPath),log(`Custom route mapped: ${urlPath} -> ${resolvedPath}`,3)}}const matchWildcardRoute=(requestPath,pattern)=>{const regexPattern=pattern.replace(/\*\*/g,"(.+)").replace(/\*/g,"([^/]+)");return new RegExp(`^${regexPattern}$`).exec(requestPath)},rescanAttempts=new Map,dynamicNoRescanPaths=new Set,shouldSkipRescan=requestPath=>config.noRescanPaths.some(pattern=>new RegExp(pattern).test(requestPath))?(log(`Skipping rescan for configured pattern: ${requestPath}`,3),!0):!!dynamicNoRescanPaths.has(requestPath)&&(log(`Skipping rescan for dynamically blacklisted path: ${requestPath}`,3),!0),handler=async(req,res)=>{await middlewareRunner.run(req,res,async()=>{const requestPath=req.url.split("?")[0];log(`${req.method} ${requestPath}`,4),log(`customRoutes keys: ${Array.from(customRoutes.keys()).join(", ")}`,4);const normalizePath=p=>{try{let np=decodeURIComponent(p);return np.startsWith("/")||(np="/"+np),np.length>1&&np.endsWith("/")&&(np=np.slice(0,-1)),np}catch(e){log(`Warning: Failed to decode URI component "${p}": ${e.message}`,1);let np=p;return np.startsWith("/")||(np="/"+np),np.length>1&&np.endsWith("/")&&(np=np.slice(0,-1)),np}},normalizedRequestPath=normalizePath(requestPath);log(`Normalized requestPath: ${normalizedRequestPath}`,4);let matchedKey=null;for(const key of customRoutes.keys())if(normalizePath(key)===normalizedRequestPath){matchedKey=key;break}if(matchedKey){const customFilePath=customRoutes.get(matchedKey);log(`Serving custom route: ${normalizedRequestPath} -> ${customFilePath}`,3);try{const{stat:stat}=await import("fs/promises");try{await stat(customFilePath),log(`Custom route file exists: ${customFilePath}`,4)}catch(e){return log(`Custom route file does NOT exist: ${customFilePath}`,1),res.writeHead(404,{"Content-Type":"text/plain"}),void res.end("Custom route file not found")}const fileContent=await readFile(customFilePath),fileExtension=path.extname(customFilePath).toLowerCase().slice(1),mimeType=config.allowedMimes[fileExtension]||"application/octet-stream";return log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`,2),res.writeHead(200,{"Content-Type":mimeType}),void res.end(fileContent)}catch(error){return log(`Error serving custom route ${normalizedRequestPath}: ${error.message}`,1),res.writeHead(500,{"Content-Type":"text/plain"}),void res.end("Internal Server Error")}}const wildcardMatch=(requestPath=>{for(const[pattern,filePath]of wildcardRoutes){const matches=matchWildcardRoute(requestPath,pattern);if(matches)return{filePath:filePath,matches:matches}}return null})(requestPath);if(wildcardMatch){const resolvedFilePath=((filePath,matches)=>{let resolvedPath=filePath,matchIndex=1;for(;resolvedPath.includes("**")&&matchIndex<matches.length;)resolvedPath=resolvedPath.replace("**",matches[matchIndex]),matchIndex++;for(;resolvedPath.includes("*")&&matchIndex<matches.length;)resolvedPath=resolvedPath.replace("*",matches[matchIndex]),matchIndex++;return path.isAbsolute(resolvedPath)?resolvedPath:path.resolve(rootPath,resolvedPath)})(wildcardMatch.filePath,wildcardMatch.matches);log(`Serving wildcard route: ${requestPath} -> ${resolvedFilePath}`,3);try{const fileContent=await readFile(resolvedFilePath),fileExtension=path.extname(resolvedFilePath).toLowerCase().slice(1),mimeType=config.allowedMimes[fileExtension]||"application/octet-stream";return log(`Serving wildcard file as ${mimeType} (${fileContent.length} bytes)`,4),res.writeHead(200,{"Content-Type":mimeType}),void res.end(fileContent)}catch(error){return log(`Error serving wildcard route ${requestPath}: ${error.message}`,1),res.writeHead(500,{"Content-Type":"text/plain"}),void res.end("Internal Server Error")}}const served=await serveFile(files,rootPath,requestPath,req.method,config,req,res,log,moduleCache);if(served||!flags.scan||shouldSkipRescan(requestPath))served||(shouldSkipRescan(requestPath)?log(`404 - Skipped rescan for: ${requestPath}`,2):log(`404 - File not found: ${requestPath}`,1),res.writeHead(404,{"Content-Type":"text/plain"}),res.end("Not Found"));else{(requestPath=>{const newAttempts=(rescanAttempts.get(requestPath)||0)+1;rescanAttempts.set(requestPath,newAttempts),newAttempts>=config.maxRescanAttempts&&(dynamicNoRescanPaths.add(requestPath),log(`Path ${requestPath} added to dynamic blacklist after ${newAttempts} failed attempts`,1)),log(`Rescan attempt ${newAttempts}/${config.maxRescanAttempts} for: ${requestPath}`,3)})(requestPath),log("File not found, rescanning directory...",1),files=await getFiles(rootPath,config,log),log(`Rescan found ${files.length} files`,2);await serveFile(files,rootPath,requestPath,req.method,config,req,res,log,moduleCache)||(log(`404 - File not found after rescan: ${requestPath}`,1),res.writeHead(404,{"Content-Type":"text/plain"}),res.end("Not Found"))}})};return handler.moduleCache=moduleCache,handler.getStats=()=>moduleCache?.getStats()||null,handler.logCacheStats=()=>moduleCache?.logStats(log),handler.clearCache=()=>moduleCache?.clear(),handler};
1
+ import path from"path";import{readFile}from"fs/promises";import{pathToFileURL}from"url";import defaultConfig from"./defaultConfig.js";import getFiles from"./getFiles.js";import findFile from"./findFile.js";import serveFile from"./serveFile.js";import MiddlewareRunner from"./middlewareRunner.js";import ModuleCache from"./moduleCache.js";import{corsMiddleware,compressionMiddleware,rateLimitMiddleware,securityMiddleware,loggingMiddleware}from"./builtinMiddleware.js";export default async(flags,log)=>{log("Initializing router",3);const rootPath=path.isAbsolute(flags.root)?flags.root:path.join(process.cwd(),flags.root);log(`Root path: ${rootPath}`,3);let config=defaultConfig;try{const configFileName=flags.config||".config.json",configPath=path.isAbsolute(configFileName)?configFileName:path.join(rootPath,configFileName);if(log(`Config file name: ${configFileName}`,3),log(`Config path: ${configPath}`,3),path.isAbsolute(configFileName))log("Config file name is absolute, skipping validation",4);else{const relativeConfigPath=path.relative(rootPath,configPath);if(log(`Relative config path: ${relativeConfigPath}`,4),log(`Starts with '..': ${relativeConfigPath.startsWith("..")}`,4),relativeConfigPath.startsWith("..")||path.isAbsolute(relativeConfigPath))throw log("Validation failed - throwing error",4),new Error(`Config file must be within the server root directory. Config path: ${configPath}, Root path: ${rootPath}`);log("Validation passed",4)}log(`Loading config from: ${configPath}`,3);const configContent=await readFile(configPath,"utf8"),userConfig=JSON.parse(configContent);config={...defaultConfig,...userConfig,allowedMimes:{...defaultConfig.allowedMimes,...userConfig.allowedMimes},middleware:{...defaultConfig.middleware,...userConfig.middleware},customRoutes:{...defaultConfig.customRoutes,...userConfig.customRoutes},cache:{...defaultConfig.cache,...userConfig.cache}},log("User config loaded and merged with defaults",3)}catch(e){if(e.message.includes("Config file must be within the server root directory"))throw e;log("Using default config (no config file found)",3)}const dis=new Set(config.disallowedRegex);dis.add("^/\\..*"),dis.add("\\.config$"),dis.add("\\.git/"),config.disallowedRegex=[...dis],log(`Config loaded with ${config.disallowedRegex.length} disallowed patterns`,3);let files=await getFiles(rootPath,config,log);log(`Initial scan found ${files.length} files`,2);const middlewareRunner=new MiddlewareRunner;if(config.middleware?.cors?.enabled&&(middlewareRunner.use(corsMiddleware(config.middleware.cors)),log("CORS middleware enabled",3)),config.middleware?.compression?.enabled&&(middlewareRunner.use(compressionMiddleware(config.middleware.compression)),log("Compression middleware enabled",3)),config.middleware?.rateLimit?.enabled&&(middlewareRunner.use(rateLimitMiddleware(config.middleware.rateLimit)),log("Rate limit middleware enabled",3)),config.middleware?.security?.enabled&&(middlewareRunner.use(securityMiddleware(config.middleware.security)),log("Security middleware enabled",3)),config.middleware?.logging?.enabled&&(middlewareRunner.use(loggingMiddleware(config.middleware.logging,log)),log("Logging middleware enabled",3)),config.middleware?.custom&&config.middleware.custom.length>0){log(`Loading ${config.middleware.custom.length} custom middleware files`,3);for(const middlewarePath of config.middleware.custom)try{const resolvedPath=path.resolve(middlewarePath),customMiddleware=(await import(pathToFileURL(resolvedPath))).default;"function"==typeof customMiddleware?(middlewareRunner.use(customMiddleware(config.middleware)),log(`Custom middleware loaded: ${middlewarePath}`,3)):log(`Custom middleware error: ${middlewarePath} does not export a default function`,1)}catch(error){log(`Custom middleware error for ${middlewarePath}: ${error.message}`,1)}}let moduleCache=null;config.cache?.enabled&&(moduleCache=new ModuleCache(config.cache),log(`Module cache initialized: ${config.cache.maxSize} max modules, ${config.cache.maxMemoryMB}MB limit, ${config.cache.ttlMs}ms TTL`,2));const customRoutes=new Map,wildcardRoutes=new Map;if(config.customRoutes&&Object.keys(config.customRoutes).length>0){log(`Processing ${Object.keys(config.customRoutes).length} custom routes`,3);for(const[urlPath,filePath]of Object.entries(config.customRoutes))if(urlPath.includes("*")){const resolvedPath=path.isAbsolute(filePath)?filePath:path.resolve(rootPath,filePath);wildcardRoutes.set(urlPath,resolvedPath),log(`Wildcard route mapped: ${urlPath} -> ${resolvedPath}`,3)}else{const resolvedPath=path.isAbsolute(filePath)?filePath:path.resolve(rootPath,filePath);customRoutes.set(urlPath,resolvedPath),log(`Custom route mapped: ${urlPath} -> ${resolvedPath}`,3)}}const matchWildcardRoute=(requestPath,pattern)=>{const regexPattern=pattern.replace(/\*\*/g,"(.+)").replace(/\*/g,"([^/]+)");return new RegExp(`^${regexPattern}$`).exec(requestPath)},rescanAttempts=new Map,dynamicNoRescanPaths=new Set,shouldSkipRescan=requestPath=>config.noRescanPaths.some(pattern=>new RegExp(pattern).test(requestPath))?(log(`Skipping rescan for configured pattern: ${requestPath}`,3),!0):!!dynamicNoRescanPaths.has(requestPath)&&(log(`Skipping rescan for dynamically blacklisted path: ${requestPath}`,3),!0),handler=async(req,res)=>{await middlewareRunner.run(req,res,async()=>{const requestPath=req.url.split("?")[0];log(`${req.method} ${requestPath}`,4),log(`customRoutes keys: ${Array.from(customRoutes.keys()).join(", ")}`,4);const normalizePath=p=>{try{let np=decodeURIComponent(p);return np.startsWith("/")||(np="/"+np),np.length>1&&np.endsWith("/")&&(np=np.slice(0,-1)),np}catch(e){log(`Warning: Failed to decode URI component "${p}": ${e.message}`,1);let np=p;return np.startsWith("/")||(np="/"+np),np.length>1&&np.endsWith("/")&&(np=np.slice(0,-1)),np}},normalizedRequestPath=normalizePath(requestPath);log(`Normalized requestPath: ${normalizedRequestPath}`,4);let matchedKey=null;for(const key of customRoutes.keys())if(normalizePath(key)===normalizedRequestPath){matchedKey=key;break}if(matchedKey){const customFilePath=customRoutes.get(matchedKey);log(`Serving custom route: ${normalizedRequestPath} -> ${customFilePath}`,3);try{const{stat:stat}=await import("fs/promises");try{await stat(customFilePath),log(`Custom route file exists: ${customFilePath}`,4)}catch(e){return log(`Custom route file does NOT exist: ${customFilePath}`,1),res.writeHead(404,{"Content-Type":"text/plain"}),void res.end("Custom route file not found")}const fileExtension=path.extname(customFilePath).toLowerCase().slice(1),mimeConfig=config.allowedMimes[fileExtension];let mimeType,encoding;"string"==typeof mimeConfig?(mimeType=mimeConfig,encoding=void 0):(mimeType=mimeConfig?.mime||"application/octet-stream",encoding="utf8"===mimeConfig?.encoding?"utf8":void 0);const fileContent=await readFile(customFilePath,encoding);return log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`,2),res.writeHead(200,{"Content-Type":mimeType}),void res.end(fileContent)}catch(error){return log(`Error serving custom route ${normalizedRequestPath}: ${error.message}`,1),res.writeHead(500,{"Content-Type":"text/plain"}),void res.end("Internal Server Error")}}const wildcardMatch=(requestPath=>{for(const[pattern,filePath]of wildcardRoutes){const matches=matchWildcardRoute(requestPath,pattern);if(matches)return{filePath:filePath,matches:matches}}return null})(requestPath);if(wildcardMatch){const resolvedFilePath=((filePath,matches)=>{let resolvedPath=filePath,matchIndex=1;for(;resolvedPath.includes("**")&&matchIndex<matches.length;)resolvedPath=resolvedPath.replace("**",matches[matchIndex]),matchIndex++;for(;resolvedPath.includes("*")&&matchIndex<matches.length;)resolvedPath=resolvedPath.replace("*",matches[matchIndex]),matchIndex++;return path.isAbsolute(resolvedPath)?resolvedPath:path.resolve(rootPath,resolvedPath)})(wildcardMatch.filePath,wildcardMatch.matches);log(`Serving wildcard route: ${requestPath} -> ${resolvedFilePath}`,3);try{const fileExtension=path.extname(resolvedFilePath).toLowerCase().slice(1),mimeConfig=config.allowedMimes[fileExtension];let mimeType,encoding;"string"==typeof mimeConfig?(mimeType=mimeConfig,encoding=void 0):(mimeType=mimeConfig?.mime||"application/octet-stream",encoding="utf8"===mimeConfig?.encoding?"utf8":void 0);const fileContent=await readFile(resolvedFilePath,encoding);return log(`Serving wildcard file as ${mimeType} (${fileContent.length} bytes)`,4),res.writeHead(200,{"Content-Type":mimeType}),void res.end(fileContent)}catch(error){return log(`Error serving wildcard route ${requestPath}: ${error.message}`,1),res.writeHead(500,{"Content-Type":"text/plain"}),void res.end("Internal Server Error")}}const served=await serveFile(files,rootPath,requestPath,req.method,config,req,res,log,moduleCache);if(served||!flags.scan||shouldSkipRescan(requestPath))served||(shouldSkipRescan(requestPath)?log(`404 - Skipped rescan for: ${requestPath}`,2):log(`404 - File not found: ${requestPath}`,1),res.writeHead(404,{"Content-Type":"text/plain"}),res.end("Not Found"));else{(requestPath=>{const newAttempts=(rescanAttempts.get(requestPath)||0)+1;rescanAttempts.set(requestPath,newAttempts),newAttempts>=config.maxRescanAttempts&&(dynamicNoRescanPaths.add(requestPath),log(`Path ${requestPath} added to dynamic blacklist after ${newAttempts} failed attempts`,1)),log(`Rescan attempt ${newAttempts}/${config.maxRescanAttempts} for: ${requestPath}`,3)})(requestPath),log("File not found, rescanning directory...",1),files=await getFiles(rootPath,config,log),log(`Rescan found ${files.length} files`,2);await serveFile(files,rootPath,requestPath,req.method,config,req,res,log,moduleCache)||(log(`404 - File not found after rescan: ${requestPath}`,1),res.writeHead(404,{"Content-Type":"text/plain"}),res.end("Not Found"))}})};return handler.moduleCache=moduleCache,handler.getStats=()=>moduleCache?.getStats()||null,handler.logCacheStats=()=>moduleCache?.logStats(log),handler.clearCache=()=>moduleCache?.clear(),handler};
package/dist/serveFile.js CHANGED
@@ -1 +1 @@
1
- import path from"path";import{readFile,stat}from"fs/promises";import{pathToFileURL}from"url";import findFile from"./findFile.js";import createRequestWrapper from"./requestWrapper.js";import createResponseWrapper from"./responseWrapper.js";export default async(files,rootPath,requestPath,method,config,req,res,log,moduleCache=null)=>{log(`Attempting to serve: ${requestPath}`,3);const[file,params]=await findFile(files,rootPath,requestPath,method,log);if(!file)return log(`No file found for: ${requestPath}`,3),!1;const fileName=path.basename(file);if(log(`Found file: ${file}`,2),config.routeFiles.includes(fileName)){log(`Executing route file: ${fileName}`,2);try{let module;if(moduleCache&&config.cache?.enabled){const fileStats=await stat(file);if(module=moduleCache.get(file,fileStats),module)log(`Using cached module: ${fileName}`,3);else{const fileUrl=pathToFileURL(file).href+`?t=${Date.now()}`;log(`Loading module from: ${fileUrl}`,3),module=await import(fileUrl);const estimatedSizeKB=fileStats.size/1024;moduleCache.set(file,module,fileStats,estimatedSizeKB),log(`Cached module: ${fileName} (${estimatedSizeKB.toFixed(1)}KB)`,3)}}else{const fileUrl=pathToFileURL(file).href+`?t=${Date.now()}`;log(`Loading module from: ${fileUrl}`,3),module=await import(fileUrl)}if("function"==typeof module.default){log(`Executing route function with params: ${JSON.stringify(params)}`,3);const enhancedRequest=createRequestWrapper(req,params),enhancedResponse=createResponseWrapper(res);return moduleCache&&(enhancedRequest._kempoCache=moduleCache),await module.default(enhancedRequest,enhancedResponse),log(`Route executed successfully: ${fileName}`,2),!0}return log(`Route file does not export a function: ${fileName}`,0),res.writeHead(500,{"Content-Type":"text/plain"}),res.end("Route file does not export a function"),!0}catch(error){return log(`Error loading route file ${fileName}: ${error.message}`,0),res.writeHead(500,{"Content-Type":"text/plain"}),res.end("Internal Server Error"),!0}}else{log(`Serving static file: ${fileName}`,2);try{const fileContent=await readFile(file),fileExtension=path.extname(file).toLowerCase().slice(1),mimeType=config.allowedMimes[fileExtension]||"application/octet-stream";return log(`Serving ${file} as ${mimeType} (${fileContent.length} bytes)`,2),res.writeHead(200,{"Content-Type":mimeType}),res.end(fileContent),!0}catch(error){return log(`Error reading file ${file}: ${error.message}`,0),res.writeHead(500,{"Content-Type":"text/plain"}),res.end("Internal Server Error"),!0}}};
1
+ import path from"path";import{readFile,stat}from"fs/promises";import{pathToFileURL}from"url";import findFile from"./findFile.js";import createRequestWrapper from"./requestWrapper.js";import createResponseWrapper from"./responseWrapper.js";export default async(files,rootPath,requestPath,method,config,req,res,log,moduleCache=null)=>{log(`Attempting to serve: ${requestPath}`,3);const[file,params]=await findFile(files,rootPath,requestPath,method,log);if(!file)return log(`No file found for: ${requestPath}`,3),!1;const fileName=path.basename(file);if(log(`Found file: ${file}`,2),config.routeFiles.includes(fileName)){log(`Executing route file: ${fileName}`,2);try{let module;if(moduleCache&&config.cache?.enabled){const fileStats=await stat(file);if(module=moduleCache.get(file,fileStats),module)log(`Using cached module: ${fileName}`,3);else{const fileUrl=pathToFileURL(file).href+`?t=${Date.now()}`;log(`Loading module from: ${fileUrl}`,3),module=await import(fileUrl);const estimatedSizeKB=fileStats.size/1024;moduleCache.set(file,module,fileStats,estimatedSizeKB),log(`Cached module: ${fileName} (${estimatedSizeKB.toFixed(1)}KB)`,3)}}else{const fileUrl=pathToFileURL(file).href+`?t=${Date.now()}`;log(`Loading module from: ${fileUrl}`,3),module=await import(fileUrl)}if("function"==typeof module.default){log(`Executing route function with params: ${JSON.stringify(params)}`,3);const enhancedRequest=createRequestWrapper(req,params),enhancedResponse=createResponseWrapper(res);return moduleCache&&(enhancedRequest._kempoCache=moduleCache),await module.default(enhancedRequest,enhancedResponse),log(`Route executed successfully: ${fileName}`,2),!0}return log(`Route file does not export a function: ${fileName}`,0),res.writeHead(500,{"Content-Type":"text/plain"}),res.end("Route file does not export a function"),!0}catch(error){return log(`Error loading route file ${fileName}: ${error.message}`,0),res.writeHead(500,{"Content-Type":"text/plain"}),res.end("Internal Server Error"),!0}}else{log(`Serving static file: ${fileName}`,2);try{const fileExtension=path.extname(file).toLowerCase().slice(1),mimeConfig=config.allowedMimes[fileExtension];let mimeType,encoding;"string"==typeof mimeConfig?(mimeType=mimeConfig,encoding=void 0):(mimeType=mimeConfig?.mime||"application/octet-stream",encoding="utf8"===mimeConfig?.encoding?"utf8":void 0);const fileContent=await readFile(file,encoding);return log(`Serving ${file} as ${mimeType} (${fileContent.length} bytes)`,2),res.writeHead(200,{"Content-Type":mimeType}),res.end(fileContent),!0}catch(error){return log(`Error reading file ${file}: ${error.message}`,0),res.writeHead(500,{"Content-Type":"text/plain"}),res.end("Internal Server Error"),!0}}};
@@ -0,0 +1 @@
1
+ import{execSync}from"child_process";import fs from"fs";import path from"path";let hasBuilt=!1;export default function ensureBuild(){if(hasBuilt)return;const distIndex=path.join(process.cwd(),"dist/index.js");if(!fs.existsSync(distIndex))try{execSync("npm run build",{stdio:"pipe"}),console.log("✓ Build completed successfully")}catch(error){throw console.error("✗ Build failed:",error.message),error}hasBuilt=!0}
@@ -32,8 +32,22 @@
32
32
  </ul>
33
33
 
34
34
  <h3 id="allowedMimes">allowedMimes</h3>
35
- <p>An object mapping file extensions to their MIME types. Files with extensions not in this list will not be served.</p>
36
- <pre><code class="hljs json">{<br /> <span class="hljs-attr">"allowedMimes"</span>: {<br /> <span class="hljs-attr">"html"</span>: <span class="hljs-string">"text/html"</span>,<br /> <span class="hljs-attr">"css"</span>: <span class="hljs-string">"text/css"</span>,<br /> <span class="hljs-attr">"js"</span>: <span class="hljs-string">"application/javascript"</span>,<br /> <span class="hljs-attr">"json"</span>: <span class="hljs-string">"application/json"</span>,<br /> <span class="hljs-attr">"png"</span>: <span class="hljs-string">"image/png"</span>,<br /> <span class="hljs-attr">"jpg"</span>: <span class="hljs-string">"image/jpeg"</span>,<br /> <span class="hljs-attr">"jpeg"</span>: <span class="hljs-string">"image/jpeg"</span>,<br /> <span class="hljs-attr">"gif"</span>: <span class="hljs-string">"image/gif"</span>,<br /> <span class="hljs-attr">"svg"</span>: <span class="hljs-string">"image/svg+xml"</span>,<br /> <span class="hljs-attr">"woff"</span>: <span class="hljs-string">"font/woff"</span>,<br /> <span class="hljs-attr">"woff2"</span>: <span class="hljs-string">"font/woff2"</span><br /> }<br />}</code></pre>
35
+ <p>An object mapping file extensions to their MIME types and encoding. Each value is an object with <code>mime</code> and <code>encoding</code> properties. Files with extensions not in this list will not be served.</p>
36
+ <pre><code class="hljs json">{
37
+ "allowedMimes": {
38
+ "html": { "mime": "text/html", "encoding": "utf8" },
39
+ "css": { "mime": "text/css", "encoding": "utf8" },
40
+ "js": { "mime": "application/javascript", "encoding": "utf8" },
41
+ "json": { "mime": "application/json", "encoding": "utf8" },
42
+ "png": { "mime": "image/png", "encoding": "binary" },
43
+ "jpg": { "mime": "image/jpeg", "encoding": "binary" },
44
+ "jpeg": { "mime": "image/jpeg", "encoding": "binary" },
45
+ "gif": { "mime": "image/gif", "encoding": "binary" },
46
+ "svg": { "mime": "image/svg+xml", "encoding": "utf8" },
47
+ "woff": { "mime": "font/woff", "encoding": "binary" },
48
+ "woff2": { "mime": "font/woff2", "encoding": "binary" }
49
+ }
50
+ }</code></pre>
37
51
 
38
52
  <h3 id="disallowedRegex">disallowedRegex</h3>
39
53
  <p>An array of regular expressions that match paths that should never be served. This provides security by preventing access to sensitive files.</p>
@@ -85,6 +99,62 @@
85
99
  <h3>Development Configuration</h3>
86
100
  <p>Create <code>.config.dev.json</code> for development:</p>
87
101
  <pre><code class="hljs json">{<br /> <span class="hljs-attr">"allowedMimes"</span>: {<br /> <span class="hljs-attr">"html"</span>: <span class="hljs-string">"text/html"</span>,<br /> <span class="hljs-attr">"css"</span>: <span class="hljs-string">"text/css"</span>,<br /> <span class="hljs-attr">"js"</span>: <span class="hljs-string">"application/javascript"</span>,<br /> <span class="hljs-attr">"json"</span>: <span class="hljs-string">"application/json"</span>,<br /> <span class="hljs-attr">"map"</span>: <span class="hljs-string">"application/json"</span><br /> },<br /> <span class="hljs-attr">"middleware"</span>: {<br /> <span class="hljs-attr">"cors"</span>: {<br /> <span class="hljs-attr">"enabled"</span>: <span class="hljs-literal">true</span>,<br /> <span class="hljs-attr">"origin"</span>: <span class="hljs-string">"*"</span><br /> },<br /> <span class="hljs-attr">"compression"</span>: {<br /> <span class="hljs-attr">"enabled"</span>: <span class="hljs-literal">false</span><br /> }<br /> }<br />}</code></pre>
102
+ <pre><code class="hljs json">{
103
+ "allowedMimes": {
104
+ "html": { "mime": "text/html", "encoding": "utf8" },
105
+ "css": { "mime": "text/css", "encoding": "utf8" },
106
+ "js": { "mime": "application/javascript", "encoding": "utf8" },
107
+ "json": { "mime": "application/json", "encoding": "utf8" },
108
+ "map": { "mime": "application/json", "encoding": "utf8" }
109
+ },
110
+ "middleware": {
111
+ "cors": {
112
+ "enabled": true,
113
+ "origin": "*"
114
+ },
115
+ "compression": {
116
+ "enabled": false
117
+ }
118
+ }
119
+ }</code></pre>
120
+ <pre><code class="hljs json">{
121
+ "allowedMimes": {
122
+ "html": { "mime": "text/html", "encoding": "utf8" },
123
+ "css": { "mime": "text/css", "encoding": "utf8" },
124
+ "js": { "mime": "application/javascript", "encoding": "utf8" },
125
+ "json": { "mime": "application/json", "encoding": "utf8" },
126
+ "png": { "mime": "image/png", "encoding": "binary" },
127
+ "jpg": { "mime": "image/jpeg", "encoding": "binary" }
128
+ },
129
+ "disallowedRegex": [
130
+ "^/\\..*",
131
+ "\\.env$",
132
+ "\\.config$",
133
+ "password",
134
+ "node_modules",
135
+ "\\.git",
136
+ "\\.map$"
137
+ ],
138
+ "middleware": {
139
+ "cors": {
140
+ "enabled": true,
141
+ "origin": "https://yourdomain.com"
142
+ },
143
+ "compression": {
144
+ "enabled": true,
145
+ "threshold": 1024
146
+ },
147
+ "security": {
148
+ "enabled": true,
149
+ "headers": {
150
+ "X-Content-Type-Options": "nosniff",
151
+ "X-Frame-Options": "DENY",
152
+ "X-XSS-Protection": "1; mode=block",
153
+ "Strict-Transport-Security": "max-age=31536000; includeSubDomains"
154
+ }
155
+ }
156
+ }
157
+ }</code></pre>
88
158
  <p>Use with: <code>kempo-server --root public --config .config.dev.json</code></p>
89
159
 
90
160
  <h3>Production Configuration</h3>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kempo-server",
3
3
  "type": "module",
4
- "version": "1.7.12",
4
+ "version": "1.8.0",
5
5
  "description": "A lightweight, zero-dependency, file based routing server.",
6
6
  "main": "dist/index.js",
7
7
  "exports": {
@@ -1,46 +1,46 @@
1
1
  export default {
2
2
  allowedMimes: {
3
- html: "text/html",
4
- htm: "text/html",
5
- shtml: "text/html",
6
- css: "text/css",
7
- xml: "text/xml",
8
- gif: "image/gif",
9
- jpeg: "image/jpeg",
10
- jpg: "image/jpeg",
11
- js: "application/javascript",
12
- mjs: "application/javascript",
13
- json: "application/json",
14
- webp: "image/webp",
15
- png: "image/png",
16
- svg: "image/svg+xml",
17
- svgz: "image/svg+xml",
18
- ico: "image/x-icon",
19
- webm: "video/webm",
20
- mp4: "video/mp4",
21
- m4v: "video/mp4",
22
- ogv: "video/ogg",
23
- mp3: "audio/mpeg",
24
- ogg: "audio/ogg",
25
- wav: "audio/wav",
26
- woff: "font/woff",
27
- woff2: "font/woff2",
28
- ttf: "font/ttf",
29
- otf: "font/otf",
30
- eot: "application/vnd.ms-fontobject",
31
- pdf: "application/pdf",
32
- txt: "text/plain",
33
- webmanifest: "application/manifest+json",
34
- md: "text/markdown",
35
- csv: "text/csv",
36
- doc: "application/msword",
37
- docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
38
- xls: "application/vnd.ms-excel",
39
- xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
40
- ppt: "application/vnd.ms-powerpoint",
41
- pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
42
- avif: "image/avif",
43
- wasm: "application/wasm"
3
+ html: { mime: "text/html", encoding: "utf8" },
4
+ htm: { mime: "text/html", encoding: "utf8" },
5
+ shtml: { mime: "text/html", encoding: "utf8" },
6
+ css: { mime: "text/css", encoding: "utf8" },
7
+ xml: { mime: "text/xml", encoding: "utf8" },
8
+ gif: { mime: "image/gif", encoding: "binary" },
9
+ jpeg: { mime: "image/jpeg", encoding: "binary" },
10
+ jpg: { mime: "image/jpeg", encoding: "binary" },
11
+ js: { mime: "application/javascript", encoding: "utf8" },
12
+ mjs: { mime: "application/javascript", encoding: "utf8" },
13
+ json: { mime: "application/json", encoding: "utf8" },
14
+ webp: { mime: "image/webp", encoding: "binary" },
15
+ png: { mime: "image/png", encoding: "binary" },
16
+ svg: { mime: "image/svg+xml", encoding: "utf8" },
17
+ svgz: { mime: "image/svg+xml", encoding: "utf8" },
18
+ ico: { mime: "image/x-icon", encoding: "binary" },
19
+ webm: { mime: "video/webm", encoding: "binary" },
20
+ mp4: { mime: "video/mp4", encoding: "binary" },
21
+ m4v: { mime: "video/mp4", encoding: "binary" },
22
+ ogv: { mime: "video/ogg", encoding: "binary" },
23
+ mp3: { mime: "audio/mpeg", encoding: "binary" },
24
+ ogg: { mime: "audio/ogg", encoding: "binary" },
25
+ wav: { mime: "audio/wav", encoding: "binary" },
26
+ woff: { mime: "font/woff", encoding: "binary" },
27
+ woff2: { mime: "font/woff2", encoding: "binary" },
28
+ ttf: { mime: "font/ttf", encoding: "binary" },
29
+ otf: { mime: "font/otf", encoding: "binary" },
30
+ eot: { mime: "application/vnd.ms-fontobject", encoding: "binary" },
31
+ pdf: { mime: "application/pdf", encoding: "binary" },
32
+ txt: { mime: "text/plain", encoding: "utf8" },
33
+ webmanifest: { mime: "application/manifest+json", encoding: "utf8" },
34
+ md: { mime: "text/markdown", encoding: "utf8" },
35
+ csv: { mime: "text/csv", encoding: "utf8" },
36
+ doc: { mime: "application/msword", encoding: "binary" },
37
+ docx: { mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", encoding: "binary" },
38
+ xls: { mime: "application/vnd.ms-excel", encoding: "binary" },
39
+ xlsx: { mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", encoding: "binary" },
40
+ ppt: { mime: "application/vnd.ms-powerpoint", encoding: "binary" },
41
+ pptx: { mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation", encoding: "binary" },
42
+ avif: { mime: "image/avif", encoding: "binary" },
43
+ wasm: { mime: "application/wasm", encoding: "binary" }
44
44
  },
45
45
  disallowedRegex: [
46
46
  "^/\\..*",
package/src/getFiles.js CHANGED
@@ -32,11 +32,10 @@ export default async (root, config, log) => {
32
32
  const isAllowedMimeType = (filePath) => {
33
33
  const ext = getExtension(filePath);
34
34
  const allowed = config.allowedMimes.hasOwnProperty(ext);
35
-
35
+ // If you ever need the MIME type, use config.allowedMimes[ext]?.mime
36
36
  if (!allowed) {
37
37
  log(`Skipping file with disallowed extension: ${path.relative(root, filePath)} (.${ext})`, 4);
38
38
  }
39
-
40
39
  return allowed;
41
40
  };
42
41
 
package/src/router.js CHANGED
@@ -310,9 +310,17 @@ export default async (flags, log) => {
310
310
  res.end('Custom route file not found');
311
311
  return;
312
312
  }
313
- const fileContent = await readFile(customFilePath);
314
313
  const fileExtension = path.extname(customFilePath).toLowerCase().slice(1);
315
- const mimeType = config.allowedMimes[fileExtension] || 'application/octet-stream';
314
+ const mimeConfig = config.allowedMimes[fileExtension];
315
+ let mimeType, encoding;
316
+ if (typeof mimeConfig === 'string') {
317
+ mimeType = mimeConfig;
318
+ encoding = undefined;
319
+ } else {
320
+ mimeType = mimeConfig?.mime || 'application/octet-stream';
321
+ encoding = mimeConfig?.encoding === 'utf8' ? 'utf8' : undefined;
322
+ }
323
+ const fileContent = await readFile(customFilePath, encoding);
316
324
  log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`, 2);
317
325
  res.writeHead(200, { 'Content-Type': mimeType });
318
326
  res.end(fileContent);
@@ -331,9 +339,17 @@ export default async (flags, log) => {
331
339
  const resolvedFilePath = resolveWildcardPath(wildcardMatch.filePath, wildcardMatch.matches);
332
340
  log(`Serving wildcard route: ${requestPath} -> ${resolvedFilePath}`, 3);
333
341
  try {
334
- const fileContent = await readFile(resolvedFilePath);
335
342
  const fileExtension = path.extname(resolvedFilePath).toLowerCase().slice(1);
336
- const mimeType = config.allowedMimes[fileExtension] || 'application/octet-stream';
343
+ const mimeConfig = config.allowedMimes[fileExtension];
344
+ let mimeType, encoding;
345
+ if (typeof mimeConfig === 'string') {
346
+ mimeType = mimeConfig;
347
+ encoding = undefined;
348
+ } else {
349
+ mimeType = mimeConfig?.mime || 'application/octet-stream';
350
+ encoding = mimeConfig?.encoding === 'utf8' ? 'utf8' : undefined;
351
+ }
352
+ const fileContent = await readFile(resolvedFilePath, encoding);
337
353
  log(`Serving wildcard file as ${mimeType} (${fileContent.length} bytes)`, 4);
338
354
  res.writeHead(200, { 'Content-Type': mimeType });
339
355
  res.end(fileContent);
package/src/serveFile.js CHANGED
@@ -79,13 +79,20 @@ export default async (files, rootPath, requestPath, method, config, req, res, lo
79
79
  return true; // Handled (even though it's an error)
80
80
  }
81
81
  } else {
82
- // Serve the file content with appropriate MIME type
82
+ // Serve the file content with appropriate MIME type and encoding
83
83
  log(`Serving static file: ${fileName}`, 2);
84
84
  try {
85
- const fileContent = await readFile(file);
86
85
  const fileExtension = path.extname(file).toLowerCase().slice(1);
87
- const mimeType = config.allowedMimes[fileExtension] || 'application/octet-stream';
88
-
86
+ const mimeConfig = config.allowedMimes[fileExtension];
87
+ let mimeType, encoding;
88
+ if (typeof mimeConfig === 'string') {
89
+ mimeType = mimeConfig;
90
+ encoding = undefined;
91
+ } else {
92
+ mimeType = mimeConfig?.mime || 'application/octet-stream';
93
+ encoding = mimeConfig?.encoding === 'utf8' ? 'utf8' : undefined;
94
+ }
95
+ const fileContent = await readFile(file, encoding);
89
96
  log(`Serving ${file} as ${mimeType} (${fileContent.length} bytes)`, 2);
90
97
  res.writeHead(200, { 'Content-Type': mimeType });
91
98
  res.end(fileContent);
@@ -1,5 +1,8 @@
1
1
  import {startNode, randomPort, httpGet, withTempDir, write} from './test-utils.js';
2
2
  import path from 'path';
3
+ import ensureBuild from './ensure-build.js';
4
+
5
+ ensureBuild();
3
6
 
4
7
  export default {
5
8
  'CLI uses default config file when no --config flag provided': async ({pass, fail}) => {
@@ -7,8 +10,8 @@ export default {
7
10
  // Create default config and test file
8
11
  const defaultConfig = {
9
12
  allowedMimes: {
10
- html: "text/html",
11
- default: "text/default"
13
+ html: { mime: "text/html", encoding: "utf8" },
14
+ default: { mime: "text/default", encoding: "utf8" }
12
15
  }
13
16
  };
14
17
  await write(dir, '.config.json', JSON.stringify(defaultConfig));
@@ -45,8 +48,8 @@ export default {
45
48
  // Create custom config and test file
46
49
  const customConfig = {
47
50
  allowedMimes: {
48
- html: "text/html",
49
- custom: "text/custom"
51
+ html: { mime: "text/html", encoding: "utf8" },
52
+ custom: { mime: "text/custom", encoding: "utf8" }
50
53
  }
51
54
  };
52
55
  await write(dir, 'dev.config.json', JSON.stringify(customConfig));
@@ -89,8 +92,8 @@ export default {
89
92
  // Create custom config and test file
90
93
  const customConfig = {
91
94
  allowedMimes: {
92
- html: "text/html",
93
- short: "text/short"
95
+ html: { mime: "text/html", encoding: "utf8" },
96
+ short: { mime: "text/short", encoding: "utf8" }
94
97
  }
95
98
  };
96
99
  await write(dir, 'short.config.json', JSON.stringify(customConfig));
@@ -134,8 +137,8 @@ export default {
134
137
  const configDir = path.join(dir, 'configs');
135
138
  const customConfig = {
136
139
  allowedMimes: {
137
- html: "text/html",
138
- absolute: "text/absolute"
140
+ html: { mime: "text/html", encoding: "utf8" },
141
+ absolute: { mime: "text/absolute", encoding: "utf8" }
139
142
  }
140
143
  };
141
144
  const configPath = await write(configDir, 'prod.config.json', JSON.stringify(customConfig));
@@ -91,8 +91,8 @@ export default {
91
91
  // Create a custom config file as .config.json (default name)
92
92
  const customConfig = {
93
93
  allowedMimes: {
94
- html: "text/html",
95
- custom: "text/custom"
94
+ html: { mime: "text/html", encoding: "utf8" },
95
+ custom: { mime: "text/custom", encoding: "utf8" }
96
96
  }
97
97
  };
98
98
  await write(dir, '.config.json', JSON.stringify(customConfig));
@@ -128,12 +128,12 @@ export default {
128
128
  'router uses custom config file with relative path': async ({pass, fail}) => {
129
129
  await withTempDir(async (dir) => {
130
130
  // Create a custom config file with different name
131
- const customConfig = {
132
- allowedMimes: {
133
- html: "text/html",
134
- special: "text/special"
135
- }
136
- };
131
+ const customConfig = {
132
+ allowedMimes: {
133
+ html: { mime: "text/html", encoding: "utf8" },
134
+ special: { mime: "text/special", encoding: "utf8" }
135
+ }
136
+ };
137
137
  await write(dir, 'dev.config.json', JSON.stringify(customConfig));
138
138
  await write(dir, 'test.special', 'special content');
139
139
 
@@ -168,12 +168,12 @@ export default {
168
168
  await withTempDir(async (dir) => {
169
169
  // Create a custom config file in different location
170
170
  const configDir = path.join(dir, 'configs');
171
- const customConfig = {
172
- allowedMimes: {
173
- html: "text/html",
174
- absolute: "text/absolute"
175
- }
176
- };
171
+ const customConfig = {
172
+ allowedMimes: {
173
+ html: { mime: "text/html", encoding: "utf8" },
174
+ absolute: { mime: "text/absolute", encoding: "utf8" }
175
+ }
176
+ };
177
177
  const configPath = await write(configDir, 'prod.config.json', JSON.stringify(customConfig));
178
178
  await write(dir, 'test.absolute', 'absolute content');
179
179
 
@@ -274,7 +274,7 @@ export default {
274
274
  // Create partial config that only overrides some settings
275
275
  const partialConfig = {
276
276
  allowedMimes: {
277
- custom: "text/custom"
277
+ custom: { mime: "text/custom", encoding: "utf8" }
278
278
  },
279
279
  maxRescanAttempts: 5
280
280
  };
@@ -3,7 +3,10 @@ import defaultConfig from '../src/defaultConfig.js';
3
3
  export default {
4
4
  'defaultConfig contains required fields and types': async ({pass, fail}) => {
5
5
  if(typeof defaultConfig !== 'object') return fail('not object');
6
- if(!defaultConfig.allowedMimes || !defaultConfig.disallowedRegex) return fail('missing keys');
6
+ if(!defaultConfig.allowedMimes || !defaultConfig.disallowedRegex) return fail('missing keys');
7
+ // Check for mime and encoding properties in a few extensions
8
+ if(!defaultConfig.allowedMimes.html || defaultConfig.allowedMimes.html.mime !== 'text/html' || defaultConfig.allowedMimes.html.encoding !== 'utf8') return fail('html mime/encoding incorrect');
9
+ if(!defaultConfig.allowedMimes.png || defaultConfig.allowedMimes.png.mime !== 'image/png' || defaultConfig.allowedMimes.png.encoding !== 'binary') return fail('png mime/encoding incorrect');
7
10
  if(!defaultConfig.routeFiles.includes('GET.js')) return fail('routeFiles missing GET.js');
8
11
  if(!defaultConfig.middleware || typeof defaultConfig.middleware !== 'object') return fail('middleware missing');
9
12
 
@@ -0,0 +1,22 @@
1
+ import { execSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ let hasBuilt = false;
6
+
7
+ export default function ensureBuild() {
8
+ if (hasBuilt) return;
9
+
10
+ const distIndex = path.join(process.cwd(), 'dist/index.js');
11
+ if (!fs.existsSync(distIndex)) {
12
+ try {
13
+ execSync('npm run build', { stdio: 'pipe' });
14
+ console.log('✓ Build completed successfully');
15
+ } catch (error) {
16
+ console.error('✗ Build failed:', error.message);
17
+ throw error;
18
+ }
19
+ }
20
+
21
+ hasBuilt = true;
22
+ }
@@ -1,5 +1,8 @@
1
1
  import {startNode, randomPort, httpGet, withTestDir, write} from './test-utils.js';
2
2
  import path from 'path';
3
+ import ensureBuild from './ensure-build.js';
4
+
5
+ ensureBuild();
3
6
 
4
7
  export default {
5
8
  'index.js CLI starts server and serves root': async ({pass, fail}) => {
@@ -0,0 +1,189 @@
1
+ import defaultConfig from '../src/defaultConfig.js';
2
+ import router from '../src/router.js';
3
+ import http from 'http';
4
+ import { mkdtemp, writeFile, rm } from 'fs/promises';
5
+ import { tmpdir } from 'os';
6
+ import path from 'path';
7
+
8
+ const withTempDir = async (callback) => {
9
+ const dir = await mkdtemp(path.join(tmpdir(), 'kempo-test-'));
10
+ try {
11
+ await callback(dir);
12
+ } finally {
13
+ await rm(dir, { recursive: true, force: true });
14
+ }
15
+ };
16
+
17
+ const write = async (dir, filename, content) => {
18
+ const filePath = path.join(dir, filename);
19
+ await writeFile(filePath, content, 'utf8');
20
+ return filePath;
21
+ };
22
+
23
+ const httpGet = (url) => new Promise((resolve, reject) => {
24
+ http.get(url, (res) => {
25
+ let data = '';
26
+ res.on('data', chunk => data += chunk);
27
+ res.on('end', () => resolve({ res, data }));
28
+ }).on('error', reject);
29
+ });
30
+
31
+ const randomPort = () => Math.floor(Math.random() * 10000) + 10000;
32
+
33
+ export default {
34
+ 'allowedMimes should merge with defaults, not replace them': async ({pass, fail}) => {
35
+ await withTempDir(async (dir) => {
36
+ const customConfig = {
37
+ allowedMimes: {
38
+ js: { mime: "text/javascript", encoding: "utf8" },
39
+ mjs: { mime: "text/javascript", encoding: "utf8" }
40
+ }
41
+ };
42
+
43
+ await write(dir, '.config.json', JSON.stringify(customConfig));
44
+ await write(dir, 'test.css', 'body { color: red; }');
45
+ await write(dir, 'test.js', 'console.log("test");');
46
+ await write(dir, 'test.html', '<html></html>');
47
+ // Check config merge for mime and encoding
48
+ const mergedConfig = { ...defaultConfig.allowedMimes, ...customConfig.allowedMimes };
49
+ if(mergedConfig.js.mime !== 'text/javascript' || mergedConfig.js.encoding !== 'utf8') return fail('js mime/encoding merge failed');
50
+ if(mergedConfig.css.mime !== 'text/css' || mergedConfig.css.encoding !== 'utf8') return fail('css mime/encoding merge failed');
51
+
52
+ const flags = {root: dir, logging: 0, scan: false, config: '.config.json'};
53
+ const logFn = () => {};
54
+ const handler = await router(flags, logFn);
55
+ const server = http.createServer(handler);
56
+ const port = randomPort();
57
+
58
+ await new Promise(r => server.listen(port, r));
59
+ await new Promise(r => setTimeout(r, 50));
60
+
61
+ try {
62
+ const cssResponse = await httpGet(`http://localhost:${port}/test.css`);
63
+ if(cssResponse.res.statusCode !== 200){
64
+ return fail('CSS file should be served');
65
+ }
66
+ if(cssResponse.res.headers['content-type'] !== 'text/css'){
67
+ return fail(`CSS should have text/css MIME type, got: ${cssResponse.res.headers['content-type']}`);
68
+ }
69
+
70
+ const jsResponse = await httpGet(`http://localhost:${port}/test.js`);
71
+ if(jsResponse.res.statusCode !== 200){
72
+ return fail('JS file should be served');
73
+ }
74
+ if(jsResponse.res.headers['content-type'] !== 'text/javascript'){
75
+ return fail(`JS should have custom text/javascript MIME type, got: ${jsResponse.res.headers['content-type']}`);
76
+ }
77
+
78
+ const htmlResponse = await httpGet(`http://localhost:${port}/test.html`);
79
+ if(htmlResponse.res.statusCode !== 200){
80
+ return fail('HTML file should be served');
81
+ }
82
+ if(htmlResponse.res.headers['content-type'] !== 'text/html'){
83
+ return fail(`HTML should have text/html MIME type, got: ${htmlResponse.res.headers['content-type']}`);
84
+ }
85
+
86
+ pass('allowedMimes merges correctly with defaults');
87
+ } finally {
88
+ server.close();
89
+ }
90
+ });
91
+ },
92
+
93
+ 'custom MIME types should override defaults': async ({pass, fail}) => {
94
+ await withTempDir(async (dir) => {
95
+ const customConfig = {
96
+ allowedMimes: {
97
+ js: { mime: "custom/javascript", encoding: "utf8" },
98
+ custom: { mime: "application/custom", encoding: "utf8" }
99
+ }
100
+ };
101
+
102
+ await write(dir, '.config.json', JSON.stringify(customConfig));
103
+ await write(dir, 'test.js', 'console.log("test");');
104
+ await write(dir, 'test.custom', 'custom content');
105
+
106
+ const flags = {root: dir, logging: 0, scan: false, config: '.config.json'};
107
+ const logFn = () => {};
108
+ const handler = await router(flags, logFn);
109
+ const server = http.createServer(handler);
110
+ const port = randomPort();
111
+
112
+ await new Promise(r => server.listen(port, r));
113
+ await new Promise(r => setTimeout(r, 50));
114
+
115
+ try {
116
+ const jsResponse = await httpGet(`http://localhost:${port}/test.js`);
117
+ if(jsResponse.res.statusCode !== 200){
118
+ return fail('JS file should be served');
119
+ }
120
+ if(jsResponse.res.headers['content-type'] !== 'custom/javascript'){
121
+ return fail(`JS should have overridden MIME type custom/javascript, got: ${jsResponse.res.headers['content-type']}`);
122
+ }
123
+ const customResponse = await httpGet(`http://localhost:${port}/test.custom`);
124
+ if(customResponse.res.statusCode !== 200){
125
+ return fail('Custom file should be served');
126
+ }
127
+ if(customResponse.res.headers['content-type'] !== 'application/custom'){
128
+ return fail(`Custom extension should have application/custom MIME type, got: ${customResponse.res.headers['content-type']}`);
129
+ }
130
+
131
+ pass('custom MIME types correctly override defaults');
132
+ } finally {
133
+ server.close();
134
+ }
135
+ });
136
+ },
137
+
138
+ 'all default MIME types should be preserved when adding custom ones': async ({pass, fail}) => {
139
+ await withTempDir(async (dir) => {
140
+ const customConfig = {
141
+ allowedMimes: {
142
+ custom: { mime: "application/custom", encoding: "utf8" }
143
+ }
144
+ };
145
+
146
+ await write(dir, '.config.json', JSON.stringify(customConfig));
147
+ await write(dir, 'test.css', 'body {}');
148
+ await write(dir, 'test.html', '<html></html>');
149
+ await write(dir, 'test.json', '{}');
150
+ await write(dir, 'test.svg', '<svg></svg>');
151
+ await write(dir, 'test.png', 'fake png');
152
+ await write(dir, 'test.custom', 'custom');
153
+
154
+ const flags = {root: dir, logging: 0, scan: false, config: '.config.json'};
155
+ const logFn = () => {};
156
+ const handler = await router(flags, logFn);
157
+ const server = http.createServer(handler);
158
+ const port = randomPort();
159
+
160
+ await new Promise(r => server.listen(port, r));
161
+ await new Promise(r => setTimeout(r, 50));
162
+
163
+ try {
164
+ const tests = [
165
+ { file: 'test.css', expectedType: 'text/css' },
166
+ { file: 'test.html', expectedType: 'text/html' },
167
+ { file: 'test.json', expectedType: 'application/json' },
168
+ { file: 'test.svg', expectedType: 'image/svg+xml' },
169
+ { file: 'test.png', expectedType: 'image/png' },
170
+ { file: 'test.custom', expectedType: 'application/custom' }
171
+ ];
172
+
173
+ for(const test of tests){
174
+ const response = await httpGet(`http://localhost:${port}/${test.file}`);
175
+ if(response.res.statusCode !== 200){
176
+ return fail(`${test.file} should be served (status ${response.res.statusCode})`);
177
+ }
178
+ if(response.res.headers['content-type'] !== test.expectedType){
179
+ return fail(`${test.file} should have ${test.expectedType} MIME type, got: ${response.res.headers['content-type']}`);
180
+ }
181
+ }
182
+
183
+ pass('all default MIME types preserved when adding custom types');
184
+ } finally {
185
+ server.close();
186
+ }
187
+ });
188
+ }
189
+ };