kempo-server 2.1.0 → 2.1.1
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.
- package/dist/router.js +1 -1
- package/package.json +1 -1
- package/src/router.js +75 -22
- package/tests/router-custom-route-dirs.node-test.js +121 -0
package/dist/router.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import path from"path";import{readFile,stat}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 createRequestWrapper,{readRawBody,parseBody}from"./requestWrapper.js";import createResponseWrapper from"./responseWrapper.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.isAbsolute(middlewarePath)?middlewarePath:path.resolve(rootPath,middlewarePath),middlewareUrl=pathToFileURL(resolvedPath).href+`?t=${Date.now()}`,customMiddleware=(await import(middlewareUrl)).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)},serveStaticCustomFile=async(filePath,res)=>{const fileExtension=path.extname(filePath).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(filePath,encoding);log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`,2);const contentType="utf8"===encoding&&mimeType.startsWith("text/")?`${mimeType}; charset=utf-8`:mimeType;res.writeHead(200,{"Content-Type":contentType}),res.end(fileContent)},executeRouteModule=async(filePath,req,res)=>{let module;if(moduleCache&&config.cache?.enabled){const fileStats=await stat(filePath);if(module=moduleCache.get(filePath,fileStats),!module){const fileUrl=pathToFileURL(filePath).href+`?t=${Date.now()}`;module=await import(fileUrl);const estimatedSizeKB=fileStats.size/1024;moduleCache.set(filePath,module,fileStats,estimatedSizeKB)}}else{const fileUrl=pathToFileURL(filePath).href+`?t=${Date.now()}`;module=await import(fileUrl)}if("function"!=typeof module.default)return log(`Route file does not export a function: ${filePath}`,0),res.writeHead(500,{"Content-Type":"text/plain"}),void res.end("Route file does not export a function");const enhancedReq=createRequestWrapper(req,{}),enhancedRes=createResponseWrapper(res),rawBody=await readRawBody(req);enhancedReq._rawBody=rawBody,enhancedReq.body=parseBody(rawBody,req.headers["content-type"]),moduleCache&&(enhancedReq._kempoCache=moduleCache),await module.default(enhancedReq,enhancedRes)},serveCustomRoutePath=async(resolvedFilePath,req,res)=>{let fileStat;try{fileStat=await stat(resolvedFilePath)}catch(e){if("ENOENT"===e.code)return null;throw e}if(fileStat.isDirectory()){const methodUpper=req.method.toUpperCase(),candidates=[`${methodUpper}.js`,`${methodUpper}.html`,"index.js","index.html","index.htm"];for(const candidate of candidates){const candidatePath=path.join(resolvedFilePath,candidate);try{await stat(candidatePath)}catch{continue}return config.routeFiles.includes(candidate)?(log(`Executing route file: ${candidatePath}`,2),await executeRouteModule(candidatePath,req,res),!0):(log(`Serving index file: ${candidatePath}`,2),await serveStaticCustomFile(candidatePath,res),!0)}return null}const fileName=path.basename(resolvedFilePath);return config.routeFiles.includes(fileName)?(log(`Executing route file: ${resolvedFilePath}`,2),await executeRouteModule(resolvedFilePath,req,res),!0):(await serveStaticCustomFile(resolvedFilePath,res),!0)},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)=>{if(parseInt(req.headers["content-length"]||"0",10)>config.maxBodySize)return res.writeHead(413,{"Content-Type":"text/plain"}),void res.end("Payload Too Large");const rawBody=await new Promise((resolve,reject)=>{if(["GET","HEAD"].includes(req.method)&&!req.headers["content-length"])return resolve("");let body="",size=0;req.on("data",chunk=>{if(size+=chunk.length,size>config.maxBodySize)return req.destroy(),void reject(new Error("Payload Too Large"));body+=chunk.toString()}),req.on("end",()=>resolve(body)),req.on("error",reject)}).catch(err=>{if("Payload Too Large"===err.message)return res.writeHead(413,{"Content-Type":"text/plain"}),res.end("Payload Too Large"),null;throw err});if(null===rawBody)return;req._bufferedBody=rawBody;const enhancedRequest=createRequestWrapper(req,{}),enhancedResponse=createResponseWrapper(res);enhancedRequest._rawBody=rawBody,enhancedRequest.body=parseBody(rawBody,req.headers["content-type"]),await middlewareRunner.run(enhancedRequest,enhancedResponse,async()=>{const requestPath=enhancedRequest.url.split("?")[0];log(`${enhancedRequest.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{if(await serveCustomRoutePath(customFilePath,req,res))return;return log(`Custom route path not found: ${customFilePath}`,1),res.writeHead(404,{"Content-Type":"text/plain"}),void res.end("Custom route file not found")}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{if(await serveCustomRoutePath(resolvedFilePath,req,res))return;log(`Wildcard route path not found: ${requestPath}`,2)}catch(error){return log(`Error serving wildcard route ${requestPath}: ${error.message}`,1),enhancedResponse.writeHead(500,{"Content-Type":"text/plain"}),void enhancedResponse.end("Internal Server Error")}}const served=await serveFile(files,rootPath,requestPath,req.method,config,req,res,log,moduleCache);if(!served&&config.maxRescanAttempts>0&&!shouldSkipRescan(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)?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),enhancedResponse.writeHead(404,{"Content-Type":"text/plain"}),enhancedResponse.end("Not Found"))}else served||(shouldSkipRescan(requestPath)?log(`404 - Skipped rescan for: ${requestPath}`,2):log(`404 - File not found: ${requestPath}`,1),enhancedResponse.writeHead(404,{"Content-Type":"text/plain"}),enhancedResponse.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,stat,readdir}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 createRequestWrapper,{readRawBody,parseBody}from"./requestWrapper.js";import createResponseWrapper from"./responseWrapper.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.isAbsolute(middlewarePath)?middlewarePath:path.resolve(rootPath,middlewarePath),middlewareUrl=pathToFileURL(resolvedPath).href+`?t=${Date.now()}`,customMiddleware=(await import(middlewareUrl)).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)},serveStaticCustomFile=async(filePath,res)=>{const fileExtension=path.extname(filePath).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(filePath,encoding);log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`,2);const contentType="utf8"===encoding&&mimeType.startsWith("text/")?`${mimeType}; charset=utf-8`:mimeType;res.writeHead(200,{"Content-Type":contentType}),res.end(fileContent)},executeRouteModule=async(filePath,req,res,params={})=>{let module;if(moduleCache&&config.cache?.enabled){const fileStats=await stat(filePath);if(module=moduleCache.get(filePath,fileStats),!module){const fileUrl=pathToFileURL(filePath).href+`?t=${Date.now()}`;module=await import(fileUrl);const estimatedSizeKB=fileStats.size/1024;moduleCache.set(filePath,module,fileStats,estimatedSizeKB)}}else{const fileUrl=pathToFileURL(filePath).href+`?t=${Date.now()}`;module=await import(fileUrl)}if("function"!=typeof module.default)return log(`Route file does not export a function: ${filePath}`,0),res.writeHead(500,{"Content-Type":"text/plain"}),void res.end("Route file does not export a function");const enhancedReq=createRequestWrapper(req,params),enhancedRes=createResponseWrapper(res),rawBody=await readRawBody(req);enhancedReq._rawBody=rawBody,enhancedReq.body=parseBody(rawBody,req.headers["content-type"]),moduleCache&&(enhancedReq._kempoCache=moduleCache),await module.default(enhancedReq,enhancedRes)},walkDynamic=async(base,segments)=>{if(0===segments.length)return{filePath:base,params:{}};const[head,...rest]=segments;let entries;try{entries=await readdir(base,{withFileTypes:!0})}catch{return null}for(const entry of entries)if(entry.name===head)if(entry.isDirectory()){const result=await walkDynamic(path.join(base,head),rest);if(result)return result}else if(entry.isFile()&&0===rest.length)return{filePath:path.join(base,head),params:{}};for(const entry of entries){if(!entry.isDirectory()||!entry.name.startsWith("[")||!entry.name.endsWith("]"))continue;const paramName=entry.name.slice(1,-1),result=await walkDynamic(path.join(base,entry.name),rest);if(result)return{filePath:result.filePath,params:{[paramName]:head,...result.params}}}return null},serveResolvedPath=async(filePath,fileStat,params,req,res)=>{if(fileStat.isDirectory()){const methodUpper=req.method.toUpperCase(),candidates=[`${methodUpper}.js`,`${methodUpper}.html`,"index.js","index.html","index.htm"];for(const candidate of candidates){const candidatePath=path.join(filePath,candidate);try{await stat(candidatePath)}catch{continue}return config.routeFiles.includes(candidate)?(log(`Executing route file: ${candidatePath}`,2),await executeRouteModule(candidatePath,req,res,params),!0):(log(`Serving index file: ${candidatePath}`,2),await serveStaticCustomFile(candidatePath,res),!0)}return null}const fileName=path.basename(filePath);return config.routeFiles.includes(fileName)?(log(`Executing route file: ${filePath}`,2),await executeRouteModule(filePath,req,res,params),!0):(await serveStaticCustomFile(filePath,res),!0)},serveCustomRoutePath=async(resolvedFilePath,req,res)=>{let fileStat;try{return fileStat=await stat(resolvedFilePath),await serveResolvedPath(resolvedFilePath,fileStat,{},req,res)}catch(e){if("ENOENT"!==e.code)throw e}let current=resolvedFilePath;const remaining=[];for(;current!==path.dirname(current);){remaining.unshift(path.basename(current)),current=path.dirname(current);try{if(!(await stat(current)).isDirectory())break;const result=await walkDynamic(current,remaining);if(!result)return null;const resolvedStat=await stat(result.filePath);return await serveResolvedPath(result.filePath,resolvedStat,result.params,req,res)}catch(e2){if("ENOENT"!==e2.code)throw e2}}return null},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)=>{if(parseInt(req.headers["content-length"]||"0",10)>config.maxBodySize)return res.writeHead(413,{"Content-Type":"text/plain"}),void res.end("Payload Too Large");const rawBody=await new Promise((resolve,reject)=>{if(["GET","HEAD"].includes(req.method)&&!req.headers["content-length"])return resolve("");let body="",size=0;req.on("data",chunk=>{if(size+=chunk.length,size>config.maxBodySize)return req.destroy(),void reject(new Error("Payload Too Large"));body+=chunk.toString()}),req.on("end",()=>resolve(body)),req.on("error",reject)}).catch(err=>{if("Payload Too Large"===err.message)return res.writeHead(413,{"Content-Type":"text/plain"}),res.end("Payload Too Large"),null;throw err});if(null===rawBody)return;req._bufferedBody=rawBody;const enhancedRequest=createRequestWrapper(req,{}),enhancedResponse=createResponseWrapper(res);enhancedRequest._rawBody=rawBody,enhancedRequest.body=parseBody(rawBody,req.headers["content-type"]),await middlewareRunner.run(enhancedRequest,enhancedResponse,async()=>{const requestPath=enhancedRequest.url.split("?")[0];log(`${enhancedRequest.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{if(await serveCustomRoutePath(customFilePath,req,res))return;return log(`Custom route path not found: ${customFilePath}`,1),res.writeHead(404,{"Content-Type":"text/plain"}),void res.end("Custom route file not found")}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{if(await serveCustomRoutePath(resolvedFilePath,req,res))return;log(`Wildcard route path not found: ${requestPath}`,2)}catch(error){return log(`Error serving wildcard route ${requestPath}: ${error.message}`,1),enhancedResponse.writeHead(500,{"Content-Type":"text/plain"}),void enhancedResponse.end("Internal Server Error")}}const served=await serveFile(files,rootPath,requestPath,req.method,config,req,res,log,moduleCache);if(!served&&config.maxRescanAttempts>0&&!shouldSkipRescan(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)?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),enhancedResponse.writeHead(404,{"Content-Type":"text/plain"}),enhancedResponse.end("Not Found"))}else served||(shouldSkipRescan(requestPath)?log(`404 - Skipped rescan for: ${requestPath}`,2):log(`404 - File not found: ${requestPath}`,1),enhancedResponse.writeHead(404,{"Content-Type":"text/plain"}),enhancedResponse.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
package/src/router.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
-
import { readFile, stat } from 'fs/promises';
|
|
2
|
+
import { readFile, stat, readdir } from 'fs/promises';
|
|
3
3
|
import { pathToFileURL } from 'url';
|
|
4
4
|
import defaultConfig from './defaultConfig.js';
|
|
5
5
|
import getFiles from './getFiles.js';
|
|
@@ -248,7 +248,7 @@ export default async (flags, log) => {
|
|
|
248
248
|
res.end(fileContent);
|
|
249
249
|
};
|
|
250
250
|
|
|
251
|
-
const executeRouteModule = async (filePath, req, res) => {
|
|
251
|
+
const executeRouteModule = async (filePath, req, res, params = {}) => {
|
|
252
252
|
let module;
|
|
253
253
|
if(moduleCache && config.cache?.enabled) {
|
|
254
254
|
const fileStats = await stat(filePath);
|
|
@@ -269,7 +269,7 @@ export default async (flags, log) => {
|
|
|
269
269
|
res.end('Route file does not export a function');
|
|
270
270
|
return;
|
|
271
271
|
}
|
|
272
|
-
const enhancedReq = createRequestWrapper(req,
|
|
272
|
+
const enhancedReq = createRequestWrapper(req, params);
|
|
273
273
|
const enhancedRes = createResponseWrapper(res);
|
|
274
274
|
const rawBody = await readRawBody(req);
|
|
275
275
|
enhancedReq._rawBody = rawBody;
|
|
@@ -278,17 +278,42 @@ export default async (flags, log) => {
|
|
|
278
278
|
await module.default(enhancedReq, enhancedRes);
|
|
279
279
|
};
|
|
280
280
|
|
|
281
|
-
//
|
|
282
|
-
// Returns
|
|
283
|
-
const
|
|
284
|
-
|
|
281
|
+
// Traverse a directory tree supporting [param] directory names.
|
|
282
|
+
// Returns { filePath, params } or null.
|
|
283
|
+
const walkDynamic = async (base, segments) => {
|
|
284
|
+
if(segments.length === 0) return { filePath: base, params: {} };
|
|
285
|
+
|
|
286
|
+
const [head, ...rest] = segments;
|
|
287
|
+
let entries;
|
|
285
288
|
try {
|
|
286
|
-
|
|
287
|
-
} catch
|
|
288
|
-
|
|
289
|
-
|
|
289
|
+
entries = await readdir(base, { withFileTypes: true });
|
|
290
|
+
} catch { return null; }
|
|
291
|
+
|
|
292
|
+
// Exact match first
|
|
293
|
+
for(const entry of entries) {
|
|
294
|
+
if(entry.name !== head) continue;
|
|
295
|
+
if(entry.isDirectory()) {
|
|
296
|
+
const result = await walkDynamic(path.join(base, head), rest);
|
|
297
|
+
if(result) return result;
|
|
298
|
+
} else if(entry.isFile() && rest.length === 0) {
|
|
299
|
+
return { filePath: path.join(base, head), params: {} };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// [param] directory match
|
|
304
|
+
for(const entry of entries) {
|
|
305
|
+
if(!entry.isDirectory() || !entry.name.startsWith('[') || !entry.name.endsWith(']')) continue;
|
|
306
|
+
const paramName = entry.name.slice(1, -1);
|
|
307
|
+
const result = await walkDynamic(path.join(base, entry.name), rest);
|
|
308
|
+
if(result) return { filePath: result.filePath, params: { [paramName]: head, ...result.params } };
|
|
290
309
|
}
|
|
291
310
|
|
|
311
|
+
return null;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Serve a resolved file or directory (fileStat already known).
|
|
315
|
+
// Returns true if handled, null if directory has no matching route/index file.
|
|
316
|
+
const serveResolvedPath = async (filePath, fileStat, params, req, res) => {
|
|
292
317
|
if(fileStat.isDirectory()) {
|
|
293
318
|
const methodUpper = req.method.toUpperCase();
|
|
294
319
|
const candidates = [
|
|
@@ -299,15 +324,11 @@ export default async (flags, log) => {
|
|
|
299
324
|
'index.htm'
|
|
300
325
|
];
|
|
301
326
|
for(const candidate of candidates) {
|
|
302
|
-
const candidatePath = path.join(
|
|
303
|
-
try {
|
|
304
|
-
await stat(candidatePath);
|
|
305
|
-
} catch {
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
327
|
+
const candidatePath = path.join(filePath, candidate);
|
|
328
|
+
try { await stat(candidatePath); } catch { continue; }
|
|
308
329
|
if(config.routeFiles.includes(candidate)) {
|
|
309
330
|
log(`Executing route file: ${candidatePath}`, 2);
|
|
310
|
-
await executeRouteModule(candidatePath, req, res);
|
|
331
|
+
await executeRouteModule(candidatePath, req, res, params);
|
|
311
332
|
return true;
|
|
312
333
|
}
|
|
313
334
|
log(`Serving index file: ${candidatePath}`, 2);
|
|
@@ -317,16 +338,48 @@ export default async (flags, log) => {
|
|
|
317
338
|
return null;
|
|
318
339
|
}
|
|
319
340
|
|
|
320
|
-
const fileName = path.basename(
|
|
341
|
+
const fileName = path.basename(filePath);
|
|
321
342
|
if(config.routeFiles.includes(fileName)) {
|
|
322
|
-
log(`Executing route file: ${
|
|
323
|
-
await executeRouteModule(
|
|
343
|
+
log(`Executing route file: ${filePath}`, 2);
|
|
344
|
+
await executeRouteModule(filePath, req, res, params);
|
|
324
345
|
return true;
|
|
325
346
|
}
|
|
326
|
-
await serveStaticCustomFile(
|
|
347
|
+
await serveStaticCustomFile(filePath, res);
|
|
327
348
|
return true;
|
|
328
349
|
};
|
|
329
350
|
|
|
351
|
+
// Resolves a custom route path supporting files, directories, and [param] segments.
|
|
352
|
+
// Returns true if handled, null if path not found.
|
|
353
|
+
const serveCustomRoutePath = async (resolvedFilePath, req, res) => {
|
|
354
|
+
let fileStat;
|
|
355
|
+
try {
|
|
356
|
+
fileStat = await stat(resolvedFilePath);
|
|
357
|
+
return await serveResolvedPath(resolvedFilePath, fileStat, {}, req, res);
|
|
358
|
+
} catch(e) {
|
|
359
|
+
if(e.code !== 'ENOENT') throw e;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Path doesn't exist literally — walk backwards to find the nearest existing
|
|
363
|
+
// ancestor directory, then traverse forward with [param] support.
|
|
364
|
+
let current = resolvedFilePath;
|
|
365
|
+
const remaining = [];
|
|
366
|
+
while(current !== path.dirname(current)) {
|
|
367
|
+
remaining.unshift(path.basename(current));
|
|
368
|
+
current = path.dirname(current);
|
|
369
|
+
try {
|
|
370
|
+
const s = await stat(current);
|
|
371
|
+
if(!s.isDirectory()) break;
|
|
372
|
+
const result = await walkDynamic(current, remaining);
|
|
373
|
+
if(!result) return null;
|
|
374
|
+
const resolvedStat = await stat(result.filePath);
|
|
375
|
+
return await serveResolvedPath(result.filePath, resolvedStat, result.params, req, res);
|
|
376
|
+
} catch(e2) {
|
|
377
|
+
if(e2.code !== 'ENOENT') throw e2;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
};
|
|
382
|
+
|
|
330
383
|
// Track 404 attempts to avoid unnecessary rescans
|
|
331
384
|
const rescanAttempts = new Map(); // path -> attempt count
|
|
332
385
|
const dynamicNoRescanPaths = new Set(); // paths that have exceeded max attempts
|
|
@@ -315,5 +315,126 @@ export default {
|
|
|
315
315
|
} catch(e) {
|
|
316
316
|
fail(e.message);
|
|
317
317
|
}
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
'wildcard route supports [param] directory segments': async ({pass, fail, log}) => {
|
|
321
|
+
try {
|
|
322
|
+
await withTempDir(async (dir) => {
|
|
323
|
+
await write(dir, 'api/users/[id]/GET.js',
|
|
324
|
+
`export default (req, res) => { res.writeHead(200, {'Content-Type':'application/json'}); res.end(JSON.stringify({id: req.params.id})); };`
|
|
325
|
+
);
|
|
326
|
+
await write(dir, 'docs/index.html', '<html></html>');
|
|
327
|
+
await write(dir, 'docs/.config.json', JSON.stringify({
|
|
328
|
+
customRoutes: { '/api/**': '../api/**' }
|
|
329
|
+
}));
|
|
330
|
+
|
|
331
|
+
const prev = process.cwd();
|
|
332
|
+
process.chdir(dir);
|
|
333
|
+
const handler = await router({root: 'docs', logging: 0}, () => {});
|
|
334
|
+
const server = http.createServer(handler);
|
|
335
|
+
const port = randomPort();
|
|
336
|
+
await new Promise(r => server.listen(port, r));
|
|
337
|
+
await new Promise(r => setTimeout(r, 50));
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const r1 = await httpGet(`http://localhost:${port}/api/users/abc123`);
|
|
341
|
+
log('status: ' + r1.res.statusCode);
|
|
342
|
+
if(r1.res.statusCode !== 200) throw new Error('expected 200, got ' + r1.res.statusCode);
|
|
343
|
+
const body = JSON.parse(r1.body.toString());
|
|
344
|
+
if(body.id !== 'abc123') throw new Error('expected id=abc123, got: ' + r1.body.toString());
|
|
345
|
+
} finally {
|
|
346
|
+
server.close();
|
|
347
|
+
process.chdir(prev);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
pass('wildcard route supports [param] directory segments');
|
|
351
|
+
} catch(e) {
|
|
352
|
+
fail(e.message);
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
'wildcard route supports multiple [param] segments': async ({pass, fail, log}) => {
|
|
357
|
+
try {
|
|
358
|
+
await withTempDir(async (dir) => {
|
|
359
|
+
await write(dir, 'api/[org]/[repo]/GET.js',
|
|
360
|
+
`export default (req, res) => { res.writeHead(200, {'Content-Type':'application/json'}); res.end(JSON.stringify({org: req.params.org, repo: req.params.repo})); };`
|
|
361
|
+
);
|
|
362
|
+
await write(dir, 'docs/index.html', '<html></html>');
|
|
363
|
+
await write(dir, 'docs/.config.json', JSON.stringify({
|
|
364
|
+
customRoutes: { '/api/**': '../api/**' }
|
|
365
|
+
}));
|
|
366
|
+
|
|
367
|
+
const prev = process.cwd();
|
|
368
|
+
process.chdir(dir);
|
|
369
|
+
const handler = await router({root: 'docs', logging: 0}, () => {});
|
|
370
|
+
const server = http.createServer(handler);
|
|
371
|
+
const port = randomPort();
|
|
372
|
+
await new Promise(r => server.listen(port, r));
|
|
373
|
+
await new Promise(r => setTimeout(r, 50));
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const r1 = await httpGet(`http://localhost:${port}/api/acme/myrepo`);
|
|
377
|
+
log('status: ' + r1.res.statusCode);
|
|
378
|
+
if(r1.res.statusCode !== 200) throw new Error('expected 200, got ' + r1.res.statusCode);
|
|
379
|
+
const body = JSON.parse(r1.body.toString());
|
|
380
|
+
if(body.org !== 'acme') throw new Error('wrong org: ' + body.org);
|
|
381
|
+
if(body.repo !== 'myrepo') throw new Error('wrong repo: ' + body.repo);
|
|
382
|
+
} finally {
|
|
383
|
+
server.close();
|
|
384
|
+
process.chdir(prev);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
pass('wildcard route supports multiple [param] segments');
|
|
388
|
+
} catch(e) {
|
|
389
|
+
fail(e.message);
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
'wildcard route [param] exact match takes priority over [param]': async ({pass, fail, log}) => {
|
|
394
|
+
try {
|
|
395
|
+
await withTempDir(async (dir) => {
|
|
396
|
+
// Both a literal and [param] directory exist
|
|
397
|
+
await write(dir, 'api/users/me/GET.js',
|
|
398
|
+
`export default (req, res) => { res.writeHead(200, {'Content-Type':'application/json'}); res.end('{"who":"me"}'); };`
|
|
399
|
+
);
|
|
400
|
+
await write(dir, 'api/users/[id]/GET.js',
|
|
401
|
+
`export default (req, res) => { res.writeHead(200, {'Content-Type':'application/json'}); res.end(JSON.stringify({id: req.params.id})); };`
|
|
402
|
+
);
|
|
403
|
+
await write(dir, 'docs/index.html', '<html></html>');
|
|
404
|
+
await write(dir, 'docs/.config.json', JSON.stringify({
|
|
405
|
+
customRoutes: { '/api/**': '../api/**' }
|
|
406
|
+
}));
|
|
407
|
+
|
|
408
|
+
const prev = process.cwd();
|
|
409
|
+
process.chdir(dir);
|
|
410
|
+
const handler = await router({root: 'docs', logging: 0}, () => {});
|
|
411
|
+
const server = http.createServer(handler);
|
|
412
|
+
const port = randomPort();
|
|
413
|
+
await new Promise(r => server.listen(port, r));
|
|
414
|
+
await new Promise(r => setTimeout(r, 50));
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
// Literal 'me' should win over [id]
|
|
418
|
+
const r1 = await httpGet(`http://localhost:${port}/api/users/me`);
|
|
419
|
+
log('me status: ' + r1.res.statusCode);
|
|
420
|
+
if(r1.res.statusCode !== 200) throw new Error('expected 200');
|
|
421
|
+
const b1 = JSON.parse(r1.body.toString());
|
|
422
|
+
if(b1.who !== 'me') throw new Error('literal match should win: ' + r1.body.toString());
|
|
423
|
+
|
|
424
|
+
// Dynamic [id] still works for other values
|
|
425
|
+
const r2 = await httpGet(`http://localhost:${port}/api/users/456`);
|
|
426
|
+
log('dynamic status: ' + r2.res.statusCode);
|
|
427
|
+
if(r2.res.statusCode !== 200) throw new Error('expected 200 for dynamic');
|
|
428
|
+
const b2 = JSON.parse(r2.body.toString());
|
|
429
|
+
if(b2.id !== '456') throw new Error('dynamic param wrong: ' + r2.body.toString());
|
|
430
|
+
} finally {
|
|
431
|
+
server.close();
|
|
432
|
+
process.chdir(prev);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
pass('wildcard route [param] exact match takes priority over [param]');
|
|
436
|
+
} catch(e) {
|
|
437
|
+
fail(e.message);
|
|
438
|
+
}
|
|
318
439
|
}
|
|
319
440
|
};
|