kempo-server 1.8.0 → 1.8.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.
Files changed (41) hide show
  1. package/dist/router.js +1 -1
  2. package/dist/serveFile.js +1 -1
  3. package/package.json +1 -1
  4. package/src/router.js +14 -4
  5. package/src/serveFile.js +7 -2
  6. package/tests/builtinMiddleware-cors.node-test.js +2 -1
  7. package/tests/builtinMiddleware.node-test.js +5 -1
  8. package/tests/config-flag-cli.node-test.js +18 -14
  9. package/tests/config-flag.node-test.js +12 -9
  10. package/tests/customRoute-outside-root.node-test.js +4 -1
  11. package/tests/example-middleware.node-test.js +3 -1
  12. package/tests/getFiles.node-test.js +3 -1
  13. package/tests/index.node-test.js +6 -2
  14. package/tests/middlewareRunner.node-test.js +2 -1
  15. package/tests/mimeTypes-merge.node-test.js +8 -8
  16. package/tests/requestWrapper.node-test.js +1 -1
  17. package/tests/responseWrapper.node-test.js +2 -1
  18. package/tests/router-middleware.node-test.js +3 -1
  19. package/tests/router-wildcard-double-asterisk.node-test.js +5 -1
  20. package/tests/router-wildcard.node-test.js +5 -1
  21. package/tests/router.node-test.js +5 -1
  22. package/tests/serveFile.node-test.js +6 -2
  23. package/tests/utils/compression.js +4 -0
  24. package/tests/utils/cookie.js +1 -0
  25. package/tests/utils/env.js +6 -0
  26. package/tests/utils/file-writer.js +9 -0
  27. package/tests/utils/http.js +9 -0
  28. package/tests/utils/logging.js +1 -0
  29. package/tests/utils/mock-req.js +19 -0
  30. package/tests/utils/mock-res.js +21 -0
  31. package/tests/utils/port.js +1 -0
  32. package/tests/utils/process.js +7 -0
  33. package/tests/utils/string-utils.js +3 -0
  34. package/tests/utils/temp-dir.js +12 -0
  35. package/tests/utils/test-dir.js +24 -0
  36. package/tests/utils/test-file-path.js +9 -0
  37. package/tests/utils/test-scenario.js +29 -0
  38. package/tests/utils/timing.js +1 -0
  39. package/dist/utils/ensure-build.js +0 -1
  40. package/tests/test-utils.js +0 -151
  41. /package/tests/{ensure-build.js → utils/ensure-build.js} +0 -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=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};
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};
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 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}}};
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=mimeType.startsWith("text/")?"utf8":void 0):(mimeType=mimeConfig?.mime||"application/octet-stream",encoding="utf8"===mimeConfig?.encoding?"utf8":void 0);const fileContent=await readFile(file,encoding);log(`Serving ${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}),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}}};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kempo-server",
3
3
  "type": "module",
4
- "version": "1.8.0",
4
+ "version": "1.8.1",
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
@@ -315,14 +315,19 @@ export default async (flags, log) => {
315
315
  let mimeType, encoding;
316
316
  if (typeof mimeConfig === 'string') {
317
317
  mimeType = mimeConfig;
318
- encoding = undefined;
318
+ // Default to UTF-8 for text MIME types
319
+ encoding = mimeType.startsWith('text/') ? 'utf8' : undefined;
319
320
  } else {
320
321
  mimeType = mimeConfig?.mime || 'application/octet-stream';
321
322
  encoding = mimeConfig?.encoding === 'utf8' ? 'utf8' : undefined;
322
323
  }
323
324
  const fileContent = await readFile(customFilePath, encoding);
324
325
  log(`Serving custom file as ${mimeType} (${fileContent.length} bytes)`, 2);
325
- res.writeHead(200, { 'Content-Type': mimeType });
326
+ // Add charset=utf-8 for text MIME types when using UTF-8 encoding
327
+ const contentType = encoding === 'utf8' && mimeType.startsWith('text/')
328
+ ? `${mimeType}; charset=utf-8`
329
+ : mimeType;
330
+ res.writeHead(200, { 'Content-Type': contentType });
326
331
  res.end(fileContent);
327
332
  return; // Successfully served custom route
328
333
  } catch (error) {
@@ -344,14 +349,19 @@ export default async (flags, log) => {
344
349
  let mimeType, encoding;
345
350
  if (typeof mimeConfig === 'string') {
346
351
  mimeType = mimeConfig;
347
- encoding = undefined;
352
+ // Default to UTF-8 for text MIME types
353
+ encoding = mimeType.startsWith('text/') ? 'utf8' : undefined;
348
354
  } else {
349
355
  mimeType = mimeConfig?.mime || 'application/octet-stream';
350
356
  encoding = mimeConfig?.encoding === 'utf8' ? 'utf8' : undefined;
351
357
  }
352
358
  const fileContent = await readFile(resolvedFilePath, encoding);
353
359
  log(`Serving wildcard file as ${mimeType} (${fileContent.length} bytes)`, 4);
354
- res.writeHead(200, { 'Content-Type': mimeType });
360
+ // Add charset=utf-8 for text MIME types when using UTF-8 encoding
361
+ const contentType = encoding === 'utf8' && mimeType.startsWith('text/')
362
+ ? `${mimeType}; charset=utf-8`
363
+ : mimeType;
364
+ res.writeHead(200, { 'Content-Type': contentType });
355
365
  res.end(fileContent);
356
366
  return; // Successfully served wildcard route
357
367
  } catch (error) {
package/src/serveFile.js CHANGED
@@ -87,14 +87,19 @@ export default async (files, rootPath, requestPath, method, config, req, res, lo
87
87
  let mimeType, encoding;
88
88
  if (typeof mimeConfig === 'string') {
89
89
  mimeType = mimeConfig;
90
- encoding = undefined;
90
+ // Default to UTF-8 for text MIME types
91
+ encoding = mimeType.startsWith('text/') ? 'utf8' : undefined;
91
92
  } else {
92
93
  mimeType = mimeConfig?.mime || 'application/octet-stream';
93
94
  encoding = mimeConfig?.encoding === 'utf8' ? 'utf8' : undefined;
94
95
  }
95
96
  const fileContent = await readFile(file, encoding);
96
97
  log(`Serving ${file} as ${mimeType} (${fileContent.length} bytes)`, 2);
97
- res.writeHead(200, { 'Content-Type': mimeType });
98
+ // Add charset=utf-8 for text MIME types when using UTF-8 encoding
99
+ const contentType = encoding === 'utf8' && mimeType.startsWith('text/')
100
+ ? `${mimeType}; charset=utf-8`
101
+ : mimeType;
102
+ res.writeHead(200, { 'Content-Type': contentType });
98
103
  res.end(fileContent);
99
104
  return true; // Successfully served
100
105
  } catch (error) {
@@ -1,5 +1,6 @@
1
1
  import {corsMiddleware} from '../src/builtinMiddleware.js';
2
- import {createMockReq, createMockRes} from './test-utils.js';
2
+ import {createMockReq} from './utils/mock-req.js';
3
+ import {createMockRes} from './utils/mock-res.js';
3
4
 
4
5
  export default {
5
6
  'cors origin array and non-OPTIONS continues to next': async ({pass, fail}) => {
@@ -1,5 +1,9 @@
1
1
  import http from 'http';
2
- import {createMockReq, createMockRes, bigString, gzipSize, setEnv} from './test-utils.js';
2
+ import {createMockReq} from './utils/mock-req.js';
3
+ import {createMockRes} from './utils/mock-res.js';
4
+ import {bigString} from './utils/string-utils.js';
5
+ import {gzipSize} from './utils/compression.js';
6
+ import {setEnv} from './utils/env.js';
3
7
  import {corsMiddleware, compressionMiddleware, rateLimitMiddleware, securityMiddleware, loggingMiddleware} from '../src/builtinMiddleware.js';
4
8
 
5
9
  export default {
@@ -1,6 +1,10 @@
1
- import {startNode, randomPort, httpGet, withTempDir, write} from './test-utils.js';
1
+ import {startNode} from './utils/process.js';
2
+ import {randomPort} from './utils/port.js';
3
+ import {httpGet} from './utils/http.js';
4
+ import {withTempDir} from './utils/temp-dir.js';
5
+ import {write} from './utils/file-writer.js';
2
6
  import path from 'path';
3
- import ensureBuild from './ensure-build.js';
7
+ import ensureBuild from './utils/ensure-build.js';
4
8
 
5
9
  ensureBuild();
6
10
 
@@ -29,8 +33,8 @@ export default {
29
33
  if (res.statusCode !== 200) {
30
34
  return fail('server should serve file with default config');
31
35
  }
32
- if (res.headers['content-type'] !== 'text/default') {
33
- return fail('should use default config mime type');
36
+ if (res.headers['content-type'] !== 'text/default; charset=utf-8') {
37
+ return fail('should use default config mime type with charset');
34
38
  }
35
39
  if (body.toString() !== 'default config content') {
36
40
  return fail('should serve correct content');
@@ -73,8 +77,8 @@ export default {
73
77
  if (res.statusCode !== 200) {
74
78
  return fail('server should serve file with custom config');
75
79
  }
76
- if (res.headers['content-type'] !== 'text/custom') {
77
- return fail('should use custom config mime type');
80
+ if (res.headers['content-type'] !== 'text/custom; charset=utf-8') {
81
+ return fail('should use custom config mime type with charset');
78
82
  }
79
83
  if (body.toString() !== 'custom config content') {
80
84
  return fail('should serve correct content');
@@ -117,8 +121,8 @@ export default {
117
121
  if (res.statusCode !== 200) {
118
122
  return fail('server should serve file with short flag config');
119
123
  }
120
- if (res.headers['content-type'] !== 'text/short') {
121
- return fail('should use short flag config mime type');
124
+ if (res.headers['content-type'] !== 'text/short; charset=utf-8') {
125
+ return fail('should use short flag config mime type with charset');
122
126
  }
123
127
  if (body.toString() !== 'short flag content') {
124
128
  return fail('should serve correct content');
@@ -162,8 +166,8 @@ export default {
162
166
  if (res.statusCode !== 200) {
163
167
  return fail('server should serve file with absolute path config');
164
168
  }
165
- if (res.headers['content-type'] !== 'text/absolute') {
166
- return fail('should use absolute path config mime type');
169
+ if (res.headers['content-type'] !== 'text/absolute; charset=utf-8') {
170
+ return fail('should use absolute path config mime type with charset');
167
171
  }
168
172
  if (body.toString() !== 'absolute path content') {
169
173
  return fail('should serve correct content');
@@ -199,8 +203,8 @@ export default {
199
203
  if (res.statusCode !== 200) {
200
204
  return fail('server should start with missing config');
201
205
  }
202
- if (res.headers['content-type'] !== 'text/html') {
203
- return fail('should use default mime types');
206
+ if (res.headers['content-type'] !== 'text/html; charset=utf-8') {
207
+ return fail('should use default mime types with charset');
204
208
  }
205
209
  if (!body.toString().includes('<h1>Home</h1>')) {
206
210
  return fail('should serve HTML content');
@@ -246,8 +250,8 @@ export default {
246
250
  if (res.statusCode !== 200) {
247
251
  return fail('custom route should work with config flag');
248
252
  }
249
- if (res.headers['content-type'] !== 'text/plain') {
250
- return fail('should use config mime type');
253
+ if (res.headers['content-type'] !== 'text/plain; charset=utf-8') {
254
+ return fail('should use config mime type with charset');
251
255
  }
252
256
  if (body.toString() !== 'source file content') {
253
257
  return fail('should serve custom route content');
@@ -1,6 +1,9 @@
1
1
  import http from 'http';
2
2
  import path from 'path';
3
- import {withTempDir, write, randomPort, httpGet} from './test-utils.js';
3
+ import {withTempDir} from './utils/temp-dir.js';
4
+ import {write} from './utils/file-writer.js';
5
+ import {randomPort} from './utils/port.js';
6
+ import {httpGet} from './utils/http.js';
4
7
  import router from '../src/router.js';
5
8
  import getFlags from '../src/getFlags.js';
6
9
 
@@ -114,8 +117,8 @@ export default {
114
117
  if (response.res.statusCode !== 200) {
115
118
  return fail('custom mime type should be served');
116
119
  }
117
- if (response.res.headers['content-type'] !== 'text/custom') {
118
- return fail('should use custom mime type');
120
+ if (response.res.headers['content-type'] !== 'text/custom; charset=utf-8') {
121
+ return fail('should use custom mime type with charset');
119
122
  }
120
123
  pass('default config file usage');
121
124
  } finally {
@@ -153,8 +156,8 @@ export default {
153
156
  if (response.res.statusCode !== 200) {
154
157
  return fail('custom config should be loaded');
155
158
  }
156
- if (response.res.headers['content-type'] !== 'text/special') {
157
- return fail('should use custom config mime type');
159
+ if (response.res.headers['content-type'] !== 'text/special; charset=utf-8') {
160
+ return fail('should use custom config mime type with charset');
158
161
  }
159
162
  pass('relative path config file usage');
160
163
  } finally {
@@ -193,8 +196,8 @@ export default {
193
196
  if (response.res.statusCode !== 200) {
194
197
  return fail('absolute config path should work');
195
198
  }
196
- if (response.res.headers['content-type'] !== 'text/absolute') {
197
- return fail('should use absolute config mime type');
199
+ if (response.res.headers['content-type'] !== 'text/absolute; charset=utf-8') {
200
+ return fail('should use absolute config mime type with charset');
198
201
  }
199
202
  pass('absolute path config file usage');
200
203
  } finally {
@@ -308,8 +311,8 @@ export default {
308
311
  if (customResponse.res.statusCode !== 200) {
309
312
  return fail('custom mime type should work');
310
313
  }
311
- if (customResponse.res.headers['content-type'] !== 'text/custom') {
312
- return fail('should use custom mime type');
314
+ if (customResponse.res.headers['content-type'] !== 'text/custom; charset=utf-8') {
315
+ return fail('should use custom mime type with charset');
313
316
  }
314
317
  pass('config merging with defaults');
315
318
  } finally {
@@ -2,7 +2,10 @@ import router from '../src/router.js';
2
2
  import http from 'http';
3
3
  import { writeFile, mkdir } from 'fs/promises';
4
4
  import path from 'path';
5
- import { randomPort, httpGet, withTempDir, write } from './test-utils.js';
5
+ import {randomPort} from './utils/port.js';
6
+ import {httpGet} from './utils/http.js';
7
+ import {withTempDir} from './utils/temp-dir.js';
8
+ import {write} from './utils/file-writer.js';
6
9
 
7
10
  /*
8
11
  This test verifies that a customRoute pointing outside the rootPath is served
@@ -1,6 +1,8 @@
1
1
  import path from 'path';
2
2
  import url from 'url';
3
- import {createMockReq, createMockRes, setEnv} from './test-utils.js';
3
+ import {createMockReq} from './utils/mock-req.js';
4
+ import {createMockRes} from './utils/mock-res.js';
5
+ import {setEnv} from './utils/env.js';
4
6
 
5
7
  // import the middleware module by file path to avoid executing index.js
6
8
  const examplePath = path.join(process.cwd(), 'tests', 'example-middleware.js');
@@ -1,7 +1,9 @@
1
1
  import getFiles from '../src/getFiles.js';
2
2
  import defaultConfig from '../src/defaultConfig.js';
3
3
  import path from 'path';
4
- import {withTestDir, write, log} from './test-utils.js';
4
+ import {withTestDir} from './utils/test-dir.js';
5
+ import {write} from './utils/file-writer.js';
6
+ import {log} from './utils/logging.js';
5
7
 
6
8
  export default {
7
9
  'scans directories recursively and filters by mime and disallowed': async ({pass, fail}) => {
@@ -1,6 +1,10 @@
1
- import {startNode, randomPort, httpGet, withTestDir, write} from './test-utils.js';
1
+ import {startNode} from './utils/process.js';
2
+ import {randomPort} from './utils/port.js';
3
+ import {httpGet} from './utils/http.js';
4
+ import {withTestDir} from './utils/test-dir.js';
5
+ import {write} from './utils/file-writer.js';
2
6
  import path from 'path';
3
- import ensureBuild from './ensure-build.js';
7
+ import ensureBuild from './utils/ensure-build.js';
4
8
 
5
9
  ensureBuild();
6
10
 
@@ -1,5 +1,6 @@
1
1
  import MiddlewareRunner from '../src/middlewareRunner.js';
2
- import {createMockReq, createMockRes} from './test-utils.js';
2
+ import {createMockReq} from './utils/mock-req.js';
3
+ import {createMockRes} from './utils/mock-res.js';
3
4
 
4
5
  export default {
5
6
  'runs middleware in order and calls finalHandler': async ({pass, fail}) => {
@@ -63,24 +63,24 @@ export default {
63
63
  if(cssResponse.res.statusCode !== 200){
64
64
  return fail('CSS file should be served');
65
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']}`);
66
+ if(cssResponse.res.headers['content-type'] !== 'text/css; charset=utf-8'){
67
+ return fail(`CSS should have text/css MIME type with charset, got: ${cssResponse.res.headers['content-type']}`);
68
68
  }
69
69
 
70
70
  const jsResponse = await httpGet(`http://localhost:${port}/test.js`);
71
71
  if(jsResponse.res.statusCode !== 200){
72
72
  return fail('JS file should be served');
73
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']}`);
74
+ if(jsResponse.res.headers['content-type'] !== 'text/javascript; charset=utf-8'){
75
+ return fail(`JS should have custom text/javascript MIME type with charset, got: ${jsResponse.res.headers['content-type']}`);
76
76
  }
77
77
 
78
78
  const htmlResponse = await httpGet(`http://localhost:${port}/test.html`);
79
79
  if(htmlResponse.res.statusCode !== 200){
80
80
  return fail('HTML file should be served');
81
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']}`);
82
+ if(htmlResponse.res.headers['content-type'] !== 'text/html; charset=utf-8'){
83
+ return fail(`HTML should have text/html MIME type with charset, got: ${htmlResponse.res.headers['content-type']}`);
84
84
  }
85
85
 
86
86
  pass('allowedMimes merges correctly with defaults');
@@ -162,8 +162,8 @@ export default {
162
162
 
163
163
  try {
164
164
  const tests = [
165
- { file: 'test.css', expectedType: 'text/css' },
166
- { file: 'test.html', expectedType: 'text/html' },
165
+ { file: 'test.css', expectedType: 'text/css; charset=utf-8' },
166
+ { file: 'test.html', expectedType: 'text/html; charset=utf-8' },
167
167
  { file: 'test.json', expectedType: 'application/json' },
168
168
  { file: 'test.svg', expectedType: 'image/svg+xml' },
169
169
  { file: 'test.png', expectedType: 'image/png' },
@@ -1,4 +1,4 @@
1
- import {createMockReq} from './test-utils.js';
1
+ import {createMockReq} from './utils/mock-req.js';
2
2
  import createRequestWrapper from '../src/requestWrapper.js';
3
3
 
4
4
  export default {
@@ -1,4 +1,5 @@
1
- import {createMockRes, parseCookies} from './test-utils.js';
1
+ import {createMockRes} from './utils/mock-res.js';
2
+ import {parseCookies} from './utils/cookie.js';
2
3
  import createResponseWrapper from '../src/responseWrapper.js';
3
4
 
4
5
  export default {
@@ -1,5 +1,7 @@
1
1
  import http from 'http';
2
- import {withTestDir, write, randomPort} from './test-utils.js';
2
+ import {withTestDir} from './utils/test-dir.js';
3
+ import {write} from './utils/file-writer.js';
4
+ import {randomPort} from './utils/port.js';
3
5
  import router from '../src/router.js';
4
6
 
5
7
  export default {
@@ -1,6 +1,10 @@
1
1
  import http from 'http';
2
2
  import path from 'path';
3
- import {withTempDir, write, randomPort, httpGet, log} from './test-utils.js';
3
+ import {withTempDir} from './utils/temp-dir.js';
4
+ import {write} from './utils/file-writer.js';
5
+ import {randomPort} from './utils/port.js';
6
+ import {httpGet} from './utils/http.js';
7
+ import {log} from './utils/logging.js';
4
8
  import router from '../src/router.js';
5
9
 
6
10
  export default {
@@ -1,6 +1,10 @@
1
1
  import http from 'http';
2
2
  import path from 'path';
3
- import {withTempDir, write, randomPort, httpGet, log} from './test-utils.js';
3
+ import {withTempDir} from './utils/temp-dir.js';
4
+ import {write} from './utils/file-writer.js';
5
+ import {randomPort} from './utils/port.js';
6
+ import {httpGet} from './utils/http.js';
7
+ import {log} from './utils/logging.js';
4
8
  import router from '../src/router.js';
5
9
 
6
10
  export default {
@@ -1,6 +1,10 @@
1
1
  import http from 'http';
2
2
  import path from 'path';
3
- import {withTestDir, write, randomPort, httpGet, log} from './test-utils.js';
3
+ import {withTestDir} from './utils/test-dir.js';
4
+ import {write} from './utils/file-writer.js';
5
+ import {randomPort} from './utils/port.js';
6
+ import {httpGet} from './utils/http.js';
7
+ import {log} from './utils/logging.js';
4
8
  import router from '../src/router.js';
5
9
  import defaultConfig from '../src/defaultConfig.js';
6
10
 
@@ -2,7 +2,11 @@ import serveFile from '../src/serveFile.js';
2
2
  import findFile from '../src/findFile.js';
3
3
  import defaultConfig from '../src/defaultConfig.js';
4
4
  import path from 'path';
5
- import {createMockReq, createMockRes, withTestDir, write, log} from './test-utils.js';
5
+ import {createMockReq} from './utils/mock-req.js';
6
+ import {createMockRes} from './utils/mock-res.js';
7
+ import {withTestDir} from './utils/test-dir.js';
8
+ import {write} from './utils/file-writer.js';
9
+ import {log} from './utils/logging.js';
6
10
 
7
11
  export default {
8
12
  'serves static file with correct mime': async ({pass, fail}) => {
@@ -14,7 +18,7 @@ export default {
14
18
  const ok = await serveFile(files, dir, '/index.html', 'GET', cfg, createMockReq(), res, log);
15
19
  if(ok !== true) return fail('should serve');
16
20
  if(res.statusCode !== 200) return fail('status');
17
- if(res.getHeader('Content-Type') !== 'text/html') return fail('mime');
21
+ if(res.getHeader('Content-Type') !== 'text/html; charset=utf-8') return fail('mime');
18
22
  });
19
23
  pass('static');
20
24
  } catch(e){ fail(e.message); }
@@ -0,0 +1,4 @@
1
+ export const gzipSize = async (buf) => {
2
+ const {gzip} = await import('zlib');
3
+ return new Promise((res, rej) => gzip(buf, (e, out) => e ? rej(e) : res(out.length)));
4
+ };
@@ -0,0 +1 @@
1
+ export const parseCookies = (setCookie) => Array.isArray(setCookie) ? setCookie : (setCookie ? [setCookie] : []);
@@ -0,0 +1,6 @@
1
+ export const setEnv = (pairs, fn) => {
2
+ const prev = {};
3
+ Object.keys(pairs).forEach(k => { prev[k] = process.env[k]; process.env[k] = pairs[k]; });
4
+ const restore = () => { Object.keys(pairs).forEach(k => { if(prev[k] === undefined){ delete process.env[k]; } else { process.env[k] = prev[k]; } }); };
5
+ return fn().finally(restore);
6
+ };
@@ -0,0 +1,9 @@
1
+ import {writeFile, mkdir} from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ export const write = async (root, rel, content = '') => {
5
+ const full = path.join(root, rel);
6
+ await mkdir(path.dirname(full), {recursive: true});
7
+ await writeFile(full, content);
8
+ return full;
9
+ };
@@ -0,0 +1,9 @@
1
+ export const httpGet = (url) => new Promise((resolve, reject) => {
2
+ import('http').then(({get}) => {
3
+ get(url, res => {
4
+ const chunks = [];
5
+ res.on('data', c => chunks.push(c));
6
+ res.on('end', () => resolve({res, body: Buffer.concat(chunks)}));
7
+ }).on('error', reject);
8
+ }).catch(reject);
9
+ });
@@ -0,0 +1 @@
1
+ export const log = (..._args) => {};
@@ -0,0 +1,19 @@
1
+ import {Readable} from 'stream';
2
+
3
+ export const createMockReq = ({method = 'GET', url = '/', headers = {}, body = null, remoteAddress = '127.0.0.1'} = {}) => {
4
+ const stream = new Readable({read(){}});
5
+ stream.method = method;
6
+ stream.url = url;
7
+ stream.headers = headers;
8
+ stream.socket = {remoteAddress};
9
+ if(body !== null && body !== undefined){
10
+ const data = typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body);
11
+ setImmediate(() => {
12
+ stream.emit('data', Buffer.from(data));
13
+ stream.emit('end');
14
+ });
15
+ } else {
16
+ setImmediate(() => stream.emit('end'));
17
+ }
18
+ return stream;
19
+ };
@@ -0,0 +1,21 @@
1
+ export const createMockRes = () => {
2
+ const headers = new Map();
3
+ let ended = false;
4
+ let statusCode = 200;
5
+ const chunks = [];
6
+ return {
7
+ get statusCode(){return statusCode;},
8
+ set statusCode(v){statusCode = v;},
9
+ setHeader: (k, v) => headers.set(k, v),
10
+ getHeader: (k) => headers.get(k),
11
+ writeHead: (code, hdrs = {}) => {
12
+ statusCode = code;
13
+ Object.entries(hdrs).forEach(([k, v]) => headers.set(k, v));
14
+ },
15
+ write: (chunk) => { if(chunk) chunks.push(Buffer.from(chunk)); return true; },
16
+ end: (chunk) => { if(chunk) chunks.push(Buffer.from(chunk)); ended = true; },
17
+ getHeaders: () => Object.fromEntries(headers),
18
+ getBody: () => Buffer.concat(chunks),
19
+ isEnded: () => ended
20
+ };
21
+ };
@@ -0,0 +1 @@
1
+ export const randomPort = () => 1024 + Math.floor(Math.random() * 50000);
@@ -0,0 +1,7 @@
1
+ export const startNode = async (args, options = {}) => {
2
+ const {spawn} = await import('child_process');
3
+ const child = spawn(process.execPath, args, {stdio: ['ignore', 'pipe', 'pipe'], ...options});
4
+ child.stdout.setEncoding('utf8');
5
+ child.stderr.setEncoding('utf8');
6
+ return child;
7
+ };
@@ -0,0 +1,3 @@
1
+ export const bigString = (size = 5000) => 'x'.repeat(size);
2
+
3
+ export const toString = (buf) => Buffer.isBuffer(buf) ? buf.toString() : String(buf);
@@ -0,0 +1,12 @@
1
+ import {mkdtemp, rm} from 'fs/promises';
2
+ import os from 'os';
3
+ import path from 'path';
4
+
5
+ export const withTempDir = async (fn) => {
6
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'kempo-tests-'));
7
+ try {
8
+ return await fn(dir);
9
+ } finally {
10
+ await rm(dir, {recursive: true, force: true});
11
+ }
12
+ };
@@ -0,0 +1,24 @@
1
+ import {mkdtemp, rm, cp} from 'fs/promises';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import {fileURLToPath} from 'url';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const TEST_SERVER_ROOT = path.join(__dirname, '..', 'test-server-root');
8
+
9
+ export const withTestDir = async (fn, {subdir = null} = {}) => {
10
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'kempo-tests-'));
11
+ try {
12
+ // Copy the test server root to the temporary directory
13
+ await cp(TEST_SERVER_ROOT, tempDir, {recursive: true});
14
+ let workingDir = tempDir;
15
+
16
+ if (subdir) {
17
+ workingDir = path.join(tempDir, subdir);
18
+ }
19
+
20
+ return await fn(workingDir);
21
+ } finally {
22
+ await rm(tempDir, {recursive: true, force: true});
23
+ }
24
+ };
@@ -0,0 +1,9 @@
1
+ import path from 'path';
2
+ import {fileURLToPath} from 'url';
3
+
4
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
5
+ const TEST_SERVER_ROOT = path.join(__dirname, '..', 'test-server-root');
6
+
7
+ export const getTestFilePath = (relativePath) => {
8
+ return path.join(TEST_SERVER_ROOT, relativePath);
9
+ };
@@ -0,0 +1,29 @@
1
+ import {writeFile, mkdir} from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ export const prepareTestScenario = async (dir, scenario) => {
5
+ switch (scenario) {
6
+ case 'basic-server':
7
+ // Already has index.html, api/GET.js, etc.
8
+ break;
9
+ case 'wildcard-routes':
10
+ await write(dir, 'docs/.config.json', JSON.stringify({
11
+ customRoutes: { '/src/**': '../src/**' }
12
+ }));
13
+ break;
14
+ case 'middleware':
15
+ await write(dir, '.config.json', JSON.stringify({
16
+ middleware: { cors: {enabled: true} }
17
+ }));
18
+ break;
19
+ default:
20
+ // No special preparation needed
21
+ }
22
+ };
23
+
24
+ const write = async (root, rel, content = '') => {
25
+ const full = path.join(root, rel);
26
+ await mkdir(path.dirname(full), {recursive: true});
27
+ await writeFile(full, content);
28
+ return full;
29
+ };
@@ -0,0 +1 @@
1
+ export const wait = ms => new Promise(r => setTimeout(r, ms));
@@ -1 +0,0 @@
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}
@@ -1,151 +0,0 @@
1
- import {Readable} from 'stream';
2
- import {mkdtemp, rm, writeFile, mkdir, cp} from 'fs/promises';
3
- import os from 'os';
4
- import path from 'path';
5
- import {fileURLToPath} from 'url';
6
-
7
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
- const TEST_SERVER_ROOT = path.join(__dirname, 'test-server-root');
9
-
10
- export const createMockReq = ({method = 'GET', url = '/', headers = {}, body = null, remoteAddress = '127.0.0.1'} = {}) => {
11
- const stream = new Readable({read(){}});
12
- stream.method = method;
13
- stream.url = url;
14
- stream.headers = headers;
15
- stream.socket = {remoteAddress};
16
- if(body !== null && body !== undefined){
17
- const data = typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body);
18
- setImmediate(() => {
19
- stream.emit('data', Buffer.from(data));
20
- stream.emit('end');
21
- });
22
- } else {
23
- setImmediate(() => stream.emit('end'));
24
- }
25
- return stream;
26
- };
27
-
28
- export const createMockRes = () => {
29
- const headers = new Map();
30
- let ended = false;
31
- let statusCode = 200;
32
- const chunks = [];
33
- return {
34
- get statusCode(){return statusCode;},
35
- set statusCode(v){statusCode = v;},
36
- setHeader: (k, v) => headers.set(k, v),
37
- getHeader: (k) => headers.get(k),
38
- writeHead: (code, hdrs = {}) => {
39
- statusCode = code;
40
- Object.entries(hdrs).forEach(([k, v]) => headers.set(k, v));
41
- },
42
- write: (chunk) => { if(chunk) chunks.push(Buffer.from(chunk)); return true; },
43
- end: (chunk) => { if(chunk) chunks.push(Buffer.from(chunk)); ended = true; },
44
- getHeaders: () => Object.fromEntries(headers),
45
- getBody: () => Buffer.concat(chunks),
46
- isEnded: () => ended
47
- };
48
- };
49
-
50
- // Legacy function for backward compatibility - will be deprecated
51
- export const withTempDir = async (fn) => {
52
- const dir = await mkdtemp(path.join(os.tmpdir(), 'kempo-tests-'));
53
- try {
54
- return await fn(dir);
55
- } finally {
56
- await rm(dir, {recursive: true, force: true});
57
- }
58
- };
59
-
60
- // New function that uses the persistent test server root as a base
61
- export const withTestDir = async (fn, {subdir = null} = {}) => {
62
- const tempDir = await mkdtemp(path.join(os.tmpdir(), 'kempo-tests-'));
63
- try {
64
- // Copy the test server root to the temporary directory
65
- await cp(TEST_SERVER_ROOT, tempDir, {recursive: true});
66
- let workingDir = tempDir;
67
-
68
- if (subdir) {
69
- workingDir = path.join(tempDir, subdir);
70
- }
71
-
72
- return await fn(workingDir);
73
- } finally {
74
- await rm(tempDir, {recursive: true, force: true});
75
- }
76
- };
77
-
78
- // Helper to get the path to a file in the test server root
79
- export const getTestFilePath = (relativePath) => {
80
- return path.join(TEST_SERVER_ROOT, relativePath);
81
- };
82
-
83
- // Helper to reset or prepare specific test scenarios
84
- export const prepareTestScenario = async (dir, scenario) => {
85
- switch (scenario) {
86
- case 'basic-server':
87
- // Already has index.html, api/GET.js, etc.
88
- break;
89
- case 'wildcard-routes':
90
- await write(dir, 'docs/.config.json', JSON.stringify({
91
- customRoutes: { '/src/**': '../src/**' }
92
- }));
93
- break;
94
- case 'middleware':
95
- await write(dir, '.config.json', JSON.stringify({
96
- middleware: { cors: {enabled: true} }
97
- }));
98
- break;
99
- default:
100
- // No special preparation needed
101
- }
102
- };
103
-
104
- export const write = async (root, rel, content = '') => {
105
- const full = path.join(root, rel);
106
- await mkdir(path.dirname(full), {recursive: true});
107
- await writeFile(full, content);
108
- return full;
109
- };
110
-
111
- export const wait = ms => new Promise(r => setTimeout(r, ms));
112
-
113
- export const log = (..._args) => {};
114
-
115
- export const bigString = (size = 5000) => 'x'.repeat(size);
116
-
117
- export const randomPort = () => 1024 + Math.floor(Math.random() * 50000);
118
-
119
- export const httpGet = (url) => new Promise((resolve, reject) => {
120
- import('http').then(({get}) => {
121
- get(url, res => {
122
- const chunks = [];
123
- res.on('data', c => chunks.push(c));
124
- res.on('end', () => resolve({res, body: Buffer.concat(chunks)}));
125
- }).on('error', reject);
126
- }).catch(reject);
127
- });
128
-
129
- export const startNode = async (args, options = {}) => {
130
- const {spawn} = await import('child_process');
131
- const child = spawn(process.execPath, args, {stdio: ['ignore', 'pipe', 'pipe'], ...options});
132
- child.stdout.setEncoding('utf8');
133
- child.stderr.setEncoding('utf8');
134
- return child;
135
- };
136
-
137
- export const setEnv = (pairs, fn) => {
138
- const prev = {};
139
- Object.keys(pairs).forEach(k => { prev[k] = process.env[k]; process.env[k] = pairs[k]; });
140
- const restore = () => { Object.keys(pairs).forEach(k => { if(prev[k] === undefined){ delete process.env[k]; } else { process.env[k] = prev[k]; } }); };
141
- return fn().finally(restore);
142
- };
143
-
144
- export const toString = (buf) => Buffer.isBuffer(buf) ? buf.toString() : String(buf);
145
-
146
- export const gzipSize = async (buf) => {
147
- const {gzip} = await import('zlib');
148
- return new Promise((res, rej) => gzip(buf, (e, out) => e ? rej(e) : res(out.length)));
149
- };
150
-
151
- export const parseCookies = (setCookie) => Array.isArray(setCookie) ? setCookie : (setCookie ? [setCookie] : []);