kempo-server 1.9.11 → 1.9.13

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:{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$","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$","config\\.php$","wp-config\\.php$","\\.DS_Store$","package\\.json$","package-lock\\.json$"],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 fileExtension=path.extname(customFilePath).toLowerCase().slice(1),mimeConfig=config.allowedMimes[fileExtension];let mimeType,encoding;"string"==typeof mimeConfig?(mimeType=mimeConfig,encoding=mimeType.startsWith("text/")?"utf8":void 0):(mimeType=mimeConfig?.mime||"application/octet-stream",encoding="utf8"===mimeConfig?.encoding?"utf8":void 0);const fileContent=await readFile(customFilePath,encoding);log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`,2);const contentType="utf8"===encoding&&mimeType.startsWith("text/")?`${mimeType}; charset=utf-8`:mimeType;return res.writeHead(200,{"Content-Type":contentType}),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=mimeType.startsWith("text/")?"utf8":void 0):(mimeType=mimeConfig?.mime||"application/octet-stream",encoding="utf8"===mimeConfig?.encoding?"utf8":void 0);const fileContent=await readFile(resolvedFilePath,encoding);log(`Serving wildcard file as ${mimeType} (${fileContent.length} bytes)`,4);const contentType="utf8"===encoding&&mimeType.startsWith("text/")?`${mimeType}; charset=utf-8`:mimeType;return res.writeHead(200,{"Content-Type":contentType}),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=mimeType.startsWith("text/")?"utf8":void 0):(mimeType=mimeConfig?.mime||"application/octet-stream",encoding="utf8"===mimeConfig?.encoding?"utf8":void 0);const fileContent=await readFile(customFilePath,encoding);log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`,2);const contentType="utf8"===encoding&&mimeType.startsWith("text/")?`${mimeType}; charset=utf-8`:mimeType;return res.writeHead(200,{"Content-Type":contentType}),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=mimeType.startsWith("text/")?"utf8":void 0):(mimeType=mimeConfig?.mime||"application/octet-stream",encoding="utf8"===mimeConfig?.encoding?"utf8":void 0);const fileContent=await readFile(resolvedFilePath,encoding);log(`Serving wildcard file as ${mimeType} (${fileContent.length} bytes)`,4);const contentType="utf8"===encoding&&mimeType.startsWith("text/")?`${mimeType}; charset=utf-8`:mimeType;return res.writeHead(200,{"Content-Type":contentType}),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{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)?rescanAttempts.delete(requestPath):((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(`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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kempo-server",
3
3
  "type": "module",
4
- "version": "1.9.11",
4
+ "version": "1.9.13",
5
5
  "description": "A lightweight, zero-dependency, file based routing server.",
6
6
  "main": "dist/index.js",
7
7
  "exports": {
package/src/router.js CHANGED
@@ -377,7 +377,6 @@ export default async (flags, log) => {
377
377
 
378
378
  // If not served and scan flag is enabled, try rescanning once (with blacklist check)
379
379
  if (!served && flags.scan && !shouldSkipRescan(requestPath)) {
380
- trackRescanAttempt(requestPath);
381
380
  log('File not found, rescanning directory...', 1);
382
381
  files = await getFiles(rootPath, config, log);
383
382
  log(`Rescan found ${files.length} files`, 2);
@@ -386,9 +385,13 @@ export default async (flags, log) => {
386
385
  const reserved = await serveFile(files, rootPath, requestPath, req.method, config, req, res, log, moduleCache);
387
386
 
388
387
  if (!reserved) {
388
+ trackRescanAttempt(requestPath);
389
389
  log(`404 - File not found after rescan: ${requestPath}`, 1);
390
390
  res.writeHead(404, { 'Content-Type': 'text/plain' });
391
391
  res.end('Not Found');
392
+ } else {
393
+ // File was found after rescan, reset the attempt counter
394
+ rescanAttempts.delete(requestPath);
392
395
  }
393
396
  } else if (!served) {
394
397
  if (shouldSkipRescan(requestPath)) {
@@ -0,0 +1,279 @@
1
+ import http from 'http';
2
+ import {withTestDir} from './utils/test-dir.js';
3
+ import {write} from './utils/file-writer.js';
4
+ import {randomPort} from './utils/port.js';
5
+ import {httpGet} from './utils/http.js';
6
+ import router from '../src/router.js';
7
+
8
+ export default {
9
+ 'rescans for new files up to maxRescanAttempts': async ({pass, fail}) => {
10
+ await withTestDir(async (dir) => {
11
+ const prev = process.cwd();
12
+ process.chdir(dir);
13
+ const flags = {root: '.', logging: 0, scan: true};
14
+ const logFn = () => {};
15
+
16
+ await write(dir, '.config.json', JSON.stringify({
17
+ maxRescanAttempts: 3
18
+ }));
19
+
20
+ const handler = await router(flags, logFn);
21
+ const server = http.createServer(handler);
22
+ const port = randomPort();
23
+ await new Promise(r => server.listen(port, r));
24
+ await new Promise(r => setTimeout(r, 50));
25
+
26
+ const miss1 = await httpGet(`http://localhost:${port}/newfile.html`);
27
+ if(miss1.res.statusCode !== 404) {
28
+ server.close();
29
+ process.chdir(prev);
30
+ return fail('should 404 on first attempt');
31
+ }
32
+
33
+ await write(dir, 'newfile.html', '<h1>New File</h1>');
34
+
35
+ const hit = await httpGet(`http://localhost:${port}/newfile.html`);
36
+ if(hit.res.statusCode !== 200) {
37
+ server.close();
38
+ process.chdir(prev);
39
+ return fail('should find file after creation on rescan');
40
+ }
41
+
42
+ if(!hit.body.toString().includes('New File')) {
43
+ server.close();
44
+ process.chdir(prev);
45
+ return fail('should serve correct content');
46
+ }
47
+
48
+ server.close();
49
+ process.chdir(prev);
50
+ });
51
+ pass('rescans and finds new files');
52
+ },
53
+
54
+ 'resets rescan counter when file is found': async ({pass, fail}) => {
55
+ await withTestDir(async (dir) => {
56
+ const prev = process.cwd();
57
+ process.chdir(dir);
58
+ const flags = {root: '.', logging: 0, scan: true};
59
+ const logFn = () => {};
60
+
61
+ await write(dir, '.config.json', JSON.stringify({
62
+ maxRescanAttempts: 2
63
+ }));
64
+
65
+ const handler = await router(flags, logFn);
66
+ const server = http.createServer(handler);
67
+ const port = randomPort();
68
+ await new Promise(r => server.listen(port, r));
69
+ await new Promise(r => setTimeout(r, 50));
70
+
71
+ const miss1 = await httpGet(`http://localhost:${port}/test.html`);
72
+ if(miss1.res.statusCode !== 404) {
73
+ server.close();
74
+ process.chdir(prev);
75
+ return fail('should 404 initially');
76
+ }
77
+
78
+ await write(dir, 'test.html', '<h1>Test</h1>');
79
+
80
+ const hit = await httpGet(`http://localhost:${port}/test.html`);
81
+ if(hit.res.statusCode !== 200) {
82
+ server.close();
83
+ process.chdir(prev);
84
+ return fail('should find file on second request');
85
+ }
86
+
87
+ await new Promise(r => setTimeout(r, 50));
88
+
89
+ const hit2 = await httpGet(`http://localhost:${port}/test.html`);
90
+ if(hit2.res.statusCode !== 200) {
91
+ server.close();
92
+ process.chdir(prev);
93
+ return fail('should continue serving found file');
94
+ }
95
+
96
+ server.close();
97
+ process.chdir(prev);
98
+ });
99
+ pass('counter reset on found file');
100
+ },
101
+
102
+ 'blacklists path after maxRescanAttempts failed rescans': async ({pass, fail}) => {
103
+ await withTestDir(async (dir) => {
104
+ const prev = process.cwd();
105
+ process.chdir(dir);
106
+ const flags = {root: '.', logging: 0, scan: true};
107
+ const logFn = () => {};
108
+
109
+ await write(dir, '.config.json', JSON.stringify({
110
+ maxRescanAttempts: 3
111
+ }));
112
+
113
+ const handler = await router(flags, logFn);
114
+ const server = http.createServer(handler);
115
+ const port = randomPort();
116
+ await new Promise(r => server.listen(port, r));
117
+ await new Promise(r => setTimeout(r, 50));
118
+
119
+ for(let i = 1; i <= 3; i++) {
120
+ const miss = await httpGet(`http://localhost:${port}/never-exists.html`);
121
+ if(miss.res.statusCode !== 404) {
122
+ server.close();
123
+ process.chdir(prev);
124
+ return fail(`should 404 on attempt ${i}`);
125
+ }
126
+ }
127
+
128
+ await write(dir, 'never-exists.html', '<h1>Now it exists</h1>');
129
+
130
+ const stillMiss = await httpGet(`http://localhost:${port}/never-exists.html`);
131
+ if(stillMiss.res.statusCode !== 404) {
132
+ server.close();
133
+ process.chdir(prev);
134
+ return fail('should still 404 after blacklisting');
135
+ }
136
+
137
+ server.close();
138
+ process.chdir(prev);
139
+ });
140
+ pass('blacklists after max attempts');
141
+ },
142
+
143
+ 'tracks rescan attempts independently per path': async ({pass, fail}) => {
144
+ await withTestDir(async (dir) => {
145
+ const prev = process.cwd();
146
+ process.chdir(dir);
147
+ const flags = {root: '.', logging: 0, scan: true};
148
+ const logFn = () => {};
149
+
150
+ await write(dir, '.config.json', JSON.stringify({
151
+ maxRescanAttempts: 2
152
+ }));
153
+
154
+ const handler = await router(flags, logFn);
155
+ const server = http.createServer(handler);
156
+ const port = randomPort();
157
+ await new Promise(r => server.listen(port, r));
158
+ await new Promise(r => setTimeout(r, 50));
159
+
160
+ const miss1a = await httpGet(`http://localhost:${port}/file-a.html`);
161
+ if(miss1a.res.statusCode !== 404) {
162
+ server.close();
163
+ process.chdir(prev);
164
+ return fail('file-a should 404 initially');
165
+ }
166
+
167
+ const miss1b = await httpGet(`http://localhost:${port}/file-b.html`);
168
+ if(miss1b.res.statusCode !== 404) {
169
+ server.close();
170
+ process.chdir(prev);
171
+ return fail('file-b should 404 initially');
172
+ }
173
+
174
+ const miss2a = await httpGet(`http://localhost:${port}/file-a.html`);
175
+ if(miss2a.res.statusCode !== 404) {
176
+ server.close();
177
+ process.chdir(prev);
178
+ return fail('file-a should 404 on attempt 2');
179
+ }
180
+
181
+ await write(dir, 'file-b.html', '<h1>File B</h1>');
182
+
183
+ const hitB = await httpGet(`http://localhost:${port}/file-b.html`);
184
+ if(hitB.res.statusCode !== 200) {
185
+ server.close();
186
+ process.chdir(prev);
187
+ return fail('file-b should be found after creation');
188
+ }
189
+
190
+ await write(dir, 'file-a.html', '<h1>File A</h1>');
191
+
192
+ const missA = await httpGet(`http://localhost:${port}/file-a.html`);
193
+ if(missA.res.statusCode !== 404) {
194
+ server.close();
195
+ process.chdir(prev);
196
+ return fail('file-a should be blacklisted after 2 attempts');
197
+ }
198
+
199
+ server.close();
200
+ process.chdir(prev);
201
+ });
202
+ pass('independent tracking per path');
203
+ },
204
+
205
+ 'respects noRescanPaths config patterns': async ({pass, fail}) => {
206
+ await withTestDir(async (dir) => {
207
+ const prev = process.cwd();
208
+ process.chdir(dir);
209
+ const flags = {root: '.', logging: 0, scan: true};
210
+ const logFn = () => {};
211
+
212
+ await write(dir, '.config.json', JSON.stringify({
213
+ maxRescanAttempts: 3,
214
+ noRescanPaths: ['/skip-.*']
215
+ }));
216
+
217
+ const handler = await router(flags, logFn);
218
+ const server = http.createServer(handler);
219
+ const port = randomPort();
220
+ await new Promise(r => server.listen(port, r));
221
+ await new Promise(r => setTimeout(r, 50));
222
+
223
+ const miss1 = await httpGet(`http://localhost:${port}/skip-this.html`);
224
+ if(miss1.res.statusCode !== 404) {
225
+ server.close();
226
+ process.chdir(prev);
227
+ return fail('should 404 initially');
228
+ }
229
+
230
+ await write(dir, 'skip-this.html', '<h1>Skip</h1>');
231
+
232
+ const stillMiss = await httpGet(`http://localhost:${port}/skip-this.html`);
233
+ if(stillMiss.res.statusCode !== 404) {
234
+ server.close();
235
+ process.chdir(prev);
236
+ return fail('should not rescan paths matching noRescanPaths');
237
+ }
238
+
239
+ server.close();
240
+ process.chdir(prev);
241
+ });
242
+ pass('respects noRescanPaths patterns');
243
+ },
244
+
245
+ 'scan flag disabled prevents rescanning': async ({pass, fail}) => {
246
+ await withTestDir(async (dir) => {
247
+ const prev = process.cwd();
248
+ process.chdir(dir);
249
+ const flags = {root: '.', logging: 0, scan: false};
250
+ const logFn = () => {};
251
+
252
+ const handler = await router(flags, logFn);
253
+ const server = http.createServer(handler);
254
+ const port = randomPort();
255
+ await new Promise(r => server.listen(port, r));
256
+ await new Promise(r => setTimeout(r, 50));
257
+
258
+ const miss1 = await httpGet(`http://localhost:${port}/no-scan.html`);
259
+ if(miss1.res.statusCode !== 404) {
260
+ server.close();
261
+ process.chdir(prev);
262
+ return fail('should 404 initially');
263
+ }
264
+
265
+ await write(dir, 'no-scan.html', '<h1>No Scan</h1>');
266
+
267
+ const stillMiss = await httpGet(`http://localhost:${port}/no-scan.html`);
268
+ if(stillMiss.res.statusCode !== 404) {
269
+ server.close();
270
+ process.chdir(prev);
271
+ return fail('should not rescan when scan flag is disabled');
272
+ }
273
+
274
+ server.close();
275
+ process.chdir(prev);
276
+ });
277
+ pass('scan disabled prevents rescan');
278
+ }
279
+ };