kempo-server 3.0.7 → 3.0.9
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/defaultConfig.js +1 -1
- package/dist/findFile.js +1 -1
- package/dist/router.js +1 -1
- package/dist/serveFile.js +1 -1
- package/dist/templating/index.js +1 -1
- package/docs/routing.html +35 -2
- package/docs-src/routing.page.html +35 -2
- package/package.json +1 -1
- package/src/defaultConfig.js +14 -3
- package/src/findFile.js +13 -3
- package/src/router.js +61 -1
- package/src/serveFile.js +16 -0
- package/src/templating/index.js +8 -2
- package/tests/router-catch-fallback.node-test.js +275 -0
- package/tests/router-page-html.node-test.js +171 -0
package/dist/defaultConfig.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export default{allowedMimes:{html:{mime:"text/html",encoding:"utf8"},htm:{mime:"text/html",encoding:"utf8"},shtml:{mime:"text/html",encoding:"utf8"},css:{mime:"text/css",encoding:"utf8"},xml:{mime:"text/xml",encoding:"utf8"},gif:{mime:"image/gif",encoding:"binary"},jpeg:{mime:"image/jpeg",encoding:"binary"},jpg:{mime:"image/jpeg",encoding:"binary"},js:{mime:"application/javascript",encoding:"utf8"},mjs:{mime:"application/javascript",encoding:"utf8"},json:{mime:"application/json",encoding:"utf8"},webp:{mime:"image/webp",encoding:"binary"},png:{mime:"image/png",encoding:"binary"},svg:{mime:"image/svg+xml",encoding:"utf8"},svgz:{mime:"image/svg+xml",encoding:"utf8"},ico:{mime:"image/x-icon",encoding:"binary"},webm:{mime:"video/webm",encoding:"binary"},mp4:{mime:"video/mp4",encoding:"binary"},m4v:{mime:"video/mp4",encoding:"binary"},ogv:{mime:"video/ogg",encoding:"binary"},mp3:{mime:"audio/mpeg",encoding:"binary"},ogg:{mime:"audio/ogg",encoding:"binary"},wav:{mime:"audio/wav",encoding:"binary"},woff:{mime:"font/woff",encoding:"binary"},woff2:{mime:"font/woff2",encoding:"binary"},ttf:{mime:"font/ttf",encoding:"binary"},otf:{mime:"font/otf",encoding:"binary"},eot:{mime:"application/vnd.ms-fontobject",encoding:"binary"},pdf:{mime:"application/pdf",encoding:"binary"},txt:{mime:"text/plain",encoding:"utf8"},webmanifest:{mime:"application/manifest+json",encoding:"utf8"},md:{mime:"text/markdown",encoding:"utf8"},csv:{mime:"text/csv",encoding:"utf8"},doc:{mime:"application/msword",encoding:"binary"},docx:{mime:"application/vnd.openxmlformats-officedocument.wordprocessingml.document",encoding:"binary"},xls:{mime:"application/vnd.ms-excel",encoding:"binary"},xlsx:{mime:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",encoding:"binary"},ppt:{mime:"application/vnd.ms-powerpoint",encoding:"binary"},pptx:{mime:"application/vnd.openxmlformats-officedocument.presentationml.presentation",encoding:"binary"},avif:{mime:"image/avif",encoding:"binary"},wasm:{mime:"application/wasm",encoding:"binary"}},disallowedRegex:["^/\\..*","\\.config\\.js$","\\.config\\.json$","\\.env$","\\.git/","\\.htaccess$","\\.htpasswd$","^/node_modules/","^/vendor/","\\.log$","\\.bak$","\\.sql$","\\.ini$","config\\.php$","wp-config\\.php$","\\.DS_Store$","package\\.json$","package-lock\\.json$","\\.template\\.html$","\\.fragment\\.html$"
|
|
1
|
+
export default{allowedMimes:{html:{mime:"text/html",encoding:"utf8"},htm:{mime:"text/html",encoding:"utf8"},shtml:{mime:"text/html",encoding:"utf8"},css:{mime:"text/css",encoding:"utf8"},xml:{mime:"text/xml",encoding:"utf8"},gif:{mime:"image/gif",encoding:"binary"},jpeg:{mime:"image/jpeg",encoding:"binary"},jpg:{mime:"image/jpeg",encoding:"binary"},js:{mime:"application/javascript",encoding:"utf8"},mjs:{mime:"application/javascript",encoding:"utf8"},json:{mime:"application/json",encoding:"utf8"},webp:{mime:"image/webp",encoding:"binary"},png:{mime:"image/png",encoding:"binary"},svg:{mime:"image/svg+xml",encoding:"utf8"},svgz:{mime:"image/svg+xml",encoding:"utf8"},ico:{mime:"image/x-icon",encoding:"binary"},webm:{mime:"video/webm",encoding:"binary"},mp4:{mime:"video/mp4",encoding:"binary"},m4v:{mime:"video/mp4",encoding:"binary"},ogv:{mime:"video/ogg",encoding:"binary"},mp3:{mime:"audio/mpeg",encoding:"binary"},ogg:{mime:"audio/ogg",encoding:"binary"},wav:{mime:"audio/wav",encoding:"binary"},woff:{mime:"font/woff",encoding:"binary"},woff2:{mime:"font/woff2",encoding:"binary"},ttf:{mime:"font/ttf",encoding:"binary"},otf:{mime:"font/otf",encoding:"binary"},eot:{mime:"application/vnd.ms-fontobject",encoding:"binary"},pdf:{mime:"application/pdf",encoding:"binary"},txt:{mime:"text/plain",encoding:"utf8"},webmanifest:{mime:"application/manifest+json",encoding:"utf8"},md:{mime:"text/markdown",encoding:"utf8"},csv:{mime:"text/csv",encoding:"utf8"},doc:{mime:"application/msword",encoding:"binary"},docx:{mime:"application/vnd.openxmlformats-officedocument.wordprocessingml.document",encoding:"binary"},xls:{mime:"application/vnd.ms-excel",encoding:"binary"},xlsx:{mime:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",encoding:"binary"},ppt:{mime:"application/vnd.ms-powerpoint",encoding:"binary"},pptx:{mime:"application/vnd.openxmlformats-officedocument.presentationml.presentation",encoding:"binary"},avif:{mime:"image/avif",encoding:"binary"},wasm:{mime:"application/wasm",encoding:"binary"}},disallowedRegex:["^/\\..*","\\.config\\.js$","\\.config\\.json$","\\.env$","\\.git/","\\.htaccess$","\\.htpasswd$","^/node_modules/","^/vendor/","\\.log$","\\.bak$","\\.sql$","\\.ini$","config\\.php$","wp-config\\.php$","\\.DS_Store$","package\\.json$","package-lock\\.json$","\\.template\\.html$","\\.fragment\\.html$"],routeFiles:["GET.js","POST.js","PUT.js","DELETE.js","HEAD.js","OPTIONS.js","PATCH.js","CONNECT.js","TRACE.js","index.js","GET.page.html","POST.page.html","PUT.page.html","DELETE.page.html","HEAD.page.html","OPTIONS.page.html","PATCH.page.html","CONNECT.page.html","TRACE.page.html","index.page.html","CATCH.js","CATCH.page.html"],noRescanPaths:["^/\\.well-known/","/favicon\\.ico$","/robots\\.txt$","/sitemap\\.xml$","/apple-touch-icon","/android-chrome-","/browserconfig\\.xml$","/manifest\\.json$","\\.map$","/__webpack_hmr$","/hot-update\\.","/sockjs-node/"],maxRescanAttempts:3,customRoutes:{},middleware:{cors:{enabled:!1,origin:"*",methods:["GET","POST","PUT","DELETE","OPTIONS"],headers:["Content-Type","Authorization"]},compression:{enabled:!1,threshold:1024},rateLimit:{enabled:!1,maxRequests:100,windowMs:6e4,message:"Too many requests"},security:{enabled:!0,headers:{"X-Content-Type-Options":"nosniff","X-Frame-Options":"DENY","X-XSS-Protection":"1; mode=block"}},logging:{enabled:!0,includeUserAgent:!1,includeResponseTime:!0},custom:[]},maxBodySize:1048576,cache:{enabled:!1,maxSize:100,maxMemoryMB:50,ttlMs:3e5,maxHeapUsagePercent:70,memoryCheckInterval:3e4,watchFiles:!0,enableMemoryMonitoring:!0},templating:{preRender:!1,ssr:!1,ssrPriority:!1,globals:{},state:{},maxFragmentDepth:10}};
|
package/dist/findFile.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import path from"path";export default(files,rootPath,requestPath,method,log)=>{log(`Finding file for: ${method} ${requestPath}`,3);let normalizeRequestPath=requestPath.startsWith("/")?requestPath.slice(1):requestPath;normalizeRequestPath.endsWith("/")&&normalizeRequestPath.length>0&&(normalizeRequestPath=normalizeRequestPath.slice(0,-1));const requestSegments=normalizeRequestPath?normalizeRequestPath.split("/"):[];log(`Normalized request path: ${normalizeRequestPath}`,4),log(`Request segments: [${requestSegments.join(", ")}]`,4);const isDynamicSegment=segment=>segment.startsWith("[")&&segment.endsWith("]"),getParamName=segment=>segment.slice(1,-1),getRelativePath=filePath=>path.relative(rootPath,filePath).replace(/\\/g,"/"),exactMatch=files.find(file=>getRelativePath(file)===normalizeRequestPath);if(exactMatch)return log(`Found exact match: ${exactMatch}`,2),[exactMatch,{}];const isDirectoryRequest="/"===requestPath||requestPath.endsWith("/")||!path.extname(requestPath);if(isDirectoryRequest){log("Processing directory request",3);const dirPath=normalizeRequestPath||"",methodUpper=method.toUpperCase(),indexFiles=[`${methodUpper}.js`,`${methodUpper}.html`,"index.js","index.html"];log(`Looking for index files: [${indexFiles.join(", ")}]`,3);for(const indexFile of indexFiles){const indexPath=dirPath?`${dirPath}/${indexFile}`:indexFile,exactIndexMatch=files.find(file=>getRelativePath(file)===indexPath);if(exactIndexMatch)return log(`Found index file: ${exactIndexMatch}`,2),[exactIndexMatch,{}]}}log("Searching for dynamic routes...",3);const dynamicMatches=[];for(const file of files){const fileSegments=getRelativePath(file).split("/"),fileName=fileSegments[fileSegments.length-1],fileDirSegments=fileSegments.slice(0,-1);if(fileDirSegments.length!==requestSegments.length)continue;const pathParams={};let isMatch=!0;for(let i=0;i<fileDirSegments.length;i++){const fileSegment=fileDirSegments[i],requestSegment=requestSegments[i];if(isDynamicSegment(fileSegment)){const paramName=getParamName(fileSegment);pathParams[paramName]=requestSegment,log(`Dynamic match: [${paramName}] = ${requestSegment}`,4)}else if(fileSegment!==requestSegment){isMatch=!1;break}}if(isMatch)if(isDirectoryRequest){const methodUpper=method.toUpperCase(),priority=[`${methodUpper}.js`,`${methodUpper}.html`,"index.js","index.html"].indexOf(fileName);-1!==priority&&(log(`Found dynamic directory match: ${file} (priority: ${priority})`,3),dynamicMatches.push({file:file,pathParams:pathParams,priority:priority}))}else log(`Found dynamic file match: ${file}`,3),dynamicMatches.push({file:file,pathParams:pathParams,priority:0})}if(dynamicMatches.length>0){dynamicMatches.sort((a,b)=>a.priority-b.priority);const bestMatch=dynamicMatches[0];return log(`Best dynamic match: ${bestMatch.file} with params: ${JSON.stringify(bestMatch.pathParams)}`,2),[bestMatch.file,bestMatch.pathParams]}return log(`No file found for: ${requestPath}`,3),[!1,{}]};
|
|
1
|
+
import path from"path";export default(files,rootPath,requestPath,method,log)=>{log(`Finding file for: ${method} ${requestPath}`,3);let normalizeRequestPath=requestPath.startsWith("/")?requestPath.slice(1):requestPath;normalizeRequestPath.endsWith("/")&&normalizeRequestPath.length>0&&(normalizeRequestPath=normalizeRequestPath.slice(0,-1));const requestSegments=normalizeRequestPath?normalizeRequestPath.split("/"):[];log(`Normalized request path: ${normalizeRequestPath}`,4),log(`Request segments: [${requestSegments.join(", ")}]`,4);const isDynamicSegment=segment=>segment.startsWith("[")&&segment.endsWith("]"),getParamName=segment=>segment.slice(1,-1),getRelativePath=filePath=>path.relative(rootPath,filePath).replace(/\\/g,"/"),exactMatch=files.find(file=>getRelativePath(file)===normalizeRequestPath);if(exactMatch)return log(`Found exact match: ${exactMatch}`,2),[exactMatch,{}];const isDirectoryRequest="/"===requestPath||requestPath.endsWith("/")||!path.extname(requestPath);if(isDirectoryRequest){log("Processing directory request",3);const dirPath=normalizeRequestPath||"",methodUpper=method.toUpperCase(),indexFiles=[`${methodUpper}.js`,`${methodUpper}.html`,`${methodUpper}.page.html`,"index.js","index.html","index.page.html","CATCH.js","CATCH.html","CATCH.page.html"];log(`Looking for index files: [${indexFiles.join(", ")}]`,3);for(const indexFile of indexFiles){const indexPath=dirPath?`${dirPath}/${indexFile}`:indexFile,exactIndexMatch=files.find(file=>getRelativePath(file)===indexPath);if(exactIndexMatch)return log(`Found index file: ${exactIndexMatch}`,2),[exactIndexMatch,{}]}}log("Searching for dynamic routes...",3);const dynamicMatches=[];for(const file of files){const fileSegments=getRelativePath(file).split("/"),fileName=fileSegments[fileSegments.length-1],fileDirSegments=fileSegments.slice(0,-1);if(fileDirSegments.length!==requestSegments.length)continue;const pathParams={};let isMatch=!0;for(let i=0;i<fileDirSegments.length;i++){const fileSegment=fileDirSegments[i],requestSegment=requestSegments[i];if(isDynamicSegment(fileSegment)){const paramName=getParamName(fileSegment);pathParams[paramName]=requestSegment,log(`Dynamic match: [${paramName}] = ${requestSegment}`,4)}else if(fileSegment!==requestSegment){isMatch=!1;break}}if(isMatch)if(isDirectoryRequest){const methodUpper=method.toUpperCase(),priority=[`${methodUpper}.js`,`${methodUpper}.html`,`${methodUpper}.page.html`,"index.js","index.html","index.page.html","CATCH.js","CATCH.html","CATCH.page.html"].indexOf(fileName);-1!==priority&&(log(`Found dynamic directory match: ${file} (priority: ${priority})`,3),dynamicMatches.push({file:file,pathParams:pathParams,priority:priority}))}else log(`Found dynamic file match: ${file}`,3),dynamicMatches.push({file:file,pathParams:pathParams,priority:0})}if(dynamicMatches.length>0){dynamicMatches.sort((a,b)=>a.priority-b.priority);const bestMatch=dynamicMatches[0];return log(`Best dynamic match: ${bestMatch.file} with params: ${JSON.stringify(bestMatch.pathParams)}`,2),[bestMatch.file,bestMatch.pathParams]}return log(`No file found for: ${requestPath}`,3),[!1,{}]};
|
package/dist/router.js
CHANGED
|
@@ -1 +1 @@
|
|
|
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";import{onRescan}from"./rescan.js";import{renderDir,renderPage}from"./templating/index.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.js",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)}let userConfig;if(log(`Loading config from: ${configPath}`,3),configPath.endsWith(".js")){const configUrl=pathToFileURL(configPath).href+`?t=${Date.now()}`;userConfig=(await import(configUrl)).default}else{const configContent=await readFile(configPath,"utf8");userConfig=JSON.parse(configContent)}if(!userConfig)throw new Error("Config file is empty or has no default export");config={...defaultConfig,...userConfig,allowedMimes:{...defaultConfig.allowedMimes,...userConfig.allowedMimes||{}},middleware:{...defaultConfig.middleware,...userConfig.middleware||{}},customRoutes:{...defaultConfig.customRoutes,...userConfig.customRoutes||{}},cache:{...defaultConfig.cache,...userConfig.cache||{}},templating:{...defaultConfig.templating,...userConfig.templating||{}}},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;const configFileName=flags.config||".config.js";if(configFileName.endsWith(".js"))try{const jsonFallback=configFileName.replace(/\.js$/,".json"),jsonPath=path.isAbsolute(jsonFallback)?jsonFallback:path.join(rootPath,jsonFallback);log(`Trying JSON fallback: ${jsonPath}`,3);const configContent=await readFile(jsonPath,"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||{}},templating:{...defaultConfig.templating,...userConfig.templating||{}}},log("User config loaded from JSON fallback",3)}catch(e2){log("Using default config (no config file found)",3)}else log("Using default config (no config file found)",3)}const dis=new Set(config.disallowedRegex);if(dis.add("^/\\..*"),dis.add("\\.config\\.js$"),dis.add("\\.config\\.json$"),dis.add("\\.git/"),config.disallowedRegex=[...dis],log(`Config loaded with ${config.disallowedRegex.length} disallowed patterns`,3),config.templating.preRender){const{globals:globals,state:state,maxFragmentDepth:maxFragmentDepth}=config.templating,count=await renderDir(rootPath,rootPath,globals,state,maxFragmentDepth);log(`Pre-rendered ${count} page(s)`,2);for(const[urlPattern,dirPath]of Object.entries(config.customRoutes||{})){const baseDirRaw=dirPath.includes("*")?dirPath.split("*")[0]:dirPath,resolvedBaseDir=(path.isAbsolute(baseDirRaw)?baseDirRaw:path.resolve(rootPath,baseDirRaw)).replace(/[/\\]+$/,"");try{if(!(await stat(resolvedBaseDir)).isDirectory())continue;const extraCount=await renderDir(resolvedBaseDir,resolvedBaseDir,globals,state,maxFragmentDepth);log(`Pre-rendered ${extraCount} page(s) from custom route: ${urlPattern}`,2)}catch{}}}let files=await getFiles(rootPath,config,log);log(`Initial scan found ${files.length} files`,2),onRescan(async done=>{try{files=await getFiles(rootPath,config,log),log(`Rescan found ${files.length} files`,2),done(null,files.length)}catch(error){log(`Rescan failed: ${error.message}`,1),done(error)}});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.startsWith("/")?pattern:"/"+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,customRootDir=null)=>{let fileStat;try{fileStat=await stat(resolvedFilePath);const result=await serveResolvedPath(resolvedFilePath,fileStat,{},req,res);if(result)return result}catch(e){if("ENOENT"!==e.code)throw e}if(!fileStat){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)break;const resolvedStat=await stat(result.filePath),served=await serveResolvedPath(result.filePath,resolvedStat,result.params,req,res);if(served)return served;break}catch(e2){if("ENOENT"!==e2.code)throw e2}}}if(config.templating?.ssr&&customRootDir){const{globals:globals,state:state,maxFragmentDepth:maxFragmentDepth}=config.templating,base=resolvedFilePath.replace(/\.html$/,"").replace(/[\/\\]+$/,"");for(const pageFile of[base+".page.html",path.join(base,"index.page.html")])try{await stat(pageFile);const html=await renderPage(pageFile,customRootDir,globals,state,maxFragmentDepth);return res.writeHead(200,{"Content-Type":"text/html; charset=utf-8"}),res.end(html),log(`SSR rendered custom route: ${pageFile}`,2),!0}catch(e){log(`SSR custom route miss for ${pageFile}: ${e.message}`,3)}}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,customFilePath))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{const customRootDir=wildcardMatch.filePath.split("*")[0].replace(/[\/\\]+$/,"");if(await serveCustomRoutePath(resolvedFilePath,req,res,customRootDir))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";import{onRescan}from"./rescan.js";import{renderDir,renderPage}from"./templating/index.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.js",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)}let userConfig;if(log(`Loading config from: ${configPath}`,3),configPath.endsWith(".js")){const configUrl=pathToFileURL(configPath).href+`?t=${Date.now()}`;userConfig=(await import(configUrl)).default}else{const configContent=await readFile(configPath,"utf8");userConfig=JSON.parse(configContent)}if(!userConfig)throw new Error("Config file is empty or has no default export");config={...defaultConfig,...userConfig,allowedMimes:{...defaultConfig.allowedMimes,...userConfig.allowedMimes||{}},middleware:{...defaultConfig.middleware,...userConfig.middleware||{}},customRoutes:{...defaultConfig.customRoutes,...userConfig.customRoutes||{}},cache:{...defaultConfig.cache,...userConfig.cache||{}},templating:{...defaultConfig.templating,...userConfig.templating||{}}},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;const configFileName=flags.config||".config.js";if(configFileName.endsWith(".js"))try{const jsonFallback=configFileName.replace(/\.js$/,".json"),jsonPath=path.isAbsolute(jsonFallback)?jsonFallback:path.join(rootPath,jsonFallback);log(`Trying JSON fallback: ${jsonPath}`,3);const configContent=await readFile(jsonPath,"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||{}},templating:{...defaultConfig.templating,...userConfig.templating||{}}},log("User config loaded from JSON fallback",3)}catch(e2){log("Using default config (no config file found)",3)}else log("Using default config (no config file found)",3)}const dis=new Set(config.disallowedRegex);if(dis.add("^/\\..*"),dis.add("\\.config\\.js$"),dis.add("\\.config\\.json$"),dis.add("\\.git/"),config.disallowedRegex=[...dis],log(`Config loaded with ${config.disallowedRegex.length} disallowed patterns`,3),config.templating.preRender){const{globals:globals,state:state,maxFragmentDepth:maxFragmentDepth}=config.templating,count=await renderDir(rootPath,rootPath,globals,state,maxFragmentDepth);log(`Pre-rendered ${count} page(s)`,2);for(const[urlPattern,dirPath]of Object.entries(config.customRoutes||{})){const baseDirRaw=dirPath.includes("*")?dirPath.split("*")[0]:dirPath,resolvedBaseDir=(path.isAbsolute(baseDirRaw)?baseDirRaw:path.resolve(rootPath,baseDirRaw)).replace(/[/\\]+$/,"");try{if(!(await stat(resolvedBaseDir)).isDirectory())continue;const extraCount=await renderDir(resolvedBaseDir,resolvedBaseDir,globals,state,maxFragmentDepth);log(`Pre-rendered ${extraCount} page(s) from custom route: ${urlPattern}`,2)}catch{}}}let files=await getFiles(rootPath,config,log);log(`Initial scan found ${files.length} files`,2),onRescan(async done=>{try{files=await getFiles(rootPath,config,log),log(`Rescan found ${files.length} files`,2),done(null,files.length)}catch(error){log(`Rescan failed: ${error.message}`,1),done(error)}});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.startsWith("/")?pattern:"/"+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`,`${methodUpper}.page.html`,"index.js","index.html","index.page.html","index.htm","CATCH.js","CATCH.html","CATCH.page.html"];for(const candidate of candidates){const candidatePath=path.join(filePath,candidate);try{await stat(candidatePath)}catch{continue}if(candidate.endsWith(".page.html")){log(`Rendering page template: ${candidatePath}`,2);const{globals:globals,state:state,maxFragmentDepth:maxFragmentDepth}=config.templating,html=await renderPage(candidatePath,rootPath,globals,state,maxFragmentDepth);return res.writeHead(200,{"Content-Type":"text/html; charset=utf-8"}),res.end(html),!0}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);if(fileName.endsWith(".page.html")){log(`Rendering page template: ${filePath}`,2);const{globals:globals,state:state,maxFragmentDepth:maxFragmentDepth}=config.templating,html=await renderPage(filePath,rootPath,globals,state,maxFragmentDepth);return res.writeHead(200,{"Content-Type":"text/html; charset=utf-8"}),res.end(html),!0}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,customRootDir=null)=>{let fileStat;try{fileStat=await stat(resolvedFilePath);const result=await serveResolvedPath(resolvedFilePath,fileStat,{},req,res);if(result)return result}catch(e){if("ENOENT"!==e.code)throw e}if(!fileStat){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)break;const resolvedStat=await stat(result.filePath),served=await serveResolvedPath(result.filePath,resolvedStat,result.params,req,res);if(served)return served;break}catch(e2){if("ENOENT"!==e2.code)throw e2}}}if(config.templating?.ssr&&customRootDir){const{globals:globals,state:state,maxFragmentDepth:maxFragmentDepth}=config.templating,base=resolvedFilePath.replace(/\.html$/,"").replace(/[\/\\]+$/,"");for(const pageFile of[base+".page.html",path.join(base,"index.page.html")])try{await stat(pageFile);const html=await renderPage(pageFile,customRootDir,globals,state,maxFragmentDepth);return res.writeHead(200,{"Content-Type":"text/html; charset=utf-8"}),res.end(html),log(`SSR rendered custom route: ${pageFile}`,2),!0}catch(e){log(`SSR custom route miss for ${pageFile}: ${e.message}`,3)}}return null},rescanAttempts=new Map,serveCatchFallback=async(requestPath,req,res)=>{const candidates=["CATCH.js","CATCH.html","CATCH.page.html"];let dir=path.join(rootPath,requestPath.startsWith("/")?requestPath.slice(1):requestPath);for(path.extname(dir)&&(dir=path.dirname(dir));dir.startsWith(rootPath);){for(const candidate of candidates){const candidatePath=path.join(dir,candidate);try{await stat(candidatePath)}catch{continue}if(log(`Serving catch fallback: ${candidatePath}`,2),"CATCH.page.html"===candidate){const{globals:globals,state:state,maxFragmentDepth:maxFragmentDepth}=config.templating,html=await renderPage(candidatePath,rootPath,globals,state,maxFragmentDepth);return res.writeHead(404,{"Content-Type":"text/html; charset=utf-8"}),res.end(html),!0}if("CATCH.js"===candidate)return await executeRouteModule(candidatePath,req,res),!0;const fileContent=await readFile(candidatePath,"utf8");return res.writeHead(404,{"Content-Type":"text/html; charset=utf-8"}),res.end(fileContent),!0}const parent=path.dirname(dir);if(parent===dir)break;dir=parent}return!1},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,customFilePath))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{const customRootDir=wildcardMatch.filePath.split("*")[0].replace(/[\/\\]+$/,"");if(await serveCustomRoutePath(resolvedFilePath,req,res,customRootDir))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);if(await serveFile(files,rootPath,requestPath,req.method,config,req,res,log,moduleCache))rescanAttempts.delete(requestPath);else{if((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),await serveCatchFallback(requestPath,req,res))return;enhancedResponse.writeHead(404,{"Content-Type":"text/plain"}),enhancedResponse.end("Not Found")}}else if(!served){if(shouldSkipRescan(requestPath)?log(`404 - Skipped rescan for: ${requestPath}`,2):log(`404 - File not found: ${requestPath}`,1),await serveCatchFallback(requestPath,req,res))return;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/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,{readRawBody,parseBody}from"./requestWrapper.js";import createResponseWrapper from"./responseWrapper.js";import{renderPage}from"./templating/index.js";const trySSR=async(rootPath,requestPath,config,res,log)=>{const htmlPath=requestPath.endsWith("/")?requestPath+"index":requestPath,pagePath=path.join(rootPath,htmlPath.replace(/\.html$/,"")+".page.html");try{await stat(pagePath);const{globals:globals,state:state,maxFragmentDepth:maxFragmentDepth}=config.templating,html=await renderPage(pagePath,rootPath,globals,state,maxFragmentDepth);return res.writeHead(200,{"Content-Type":"text/html; charset=utf-8"}),res.end(html),log(`SSR rendered: ${pagePath}`,2),!0}catch(e){return log(`SSR error for ${requestPath}: ${e.message}`,3),!1}};export default async(files,rootPath,requestPath,method,config,req,res,log,moduleCache=null)=>{if(log(`Attempting to serve: ${requestPath}`,3),config.templating?.ssr&&config.templating?.ssrPriority&&await trySSR(rootPath,requestPath,config,res,log))return!0;const[file,params]=await findFile(files,rootPath,requestPath,method,log);if(!file){if(config.templating?.ssr){if(await trySSR(rootPath,requestPath,config,res,log))return!0;log(`SSR fallback not available for: ${requestPath}`,3)}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),rawBody=await readRawBody(req);return enhancedRequest._rawBody=rawBody,enhancedRequest.body=parseBody(rawBody,req.headers["content-type"]),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}}};
|
|
1
|
+
import path from"path";import{readFile,stat}from"fs/promises";import{pathToFileURL}from"url";import findFile from"./findFile.js";import createRequestWrapper,{readRawBody,parseBody}from"./requestWrapper.js";import createResponseWrapper from"./responseWrapper.js";import{renderPage}from"./templating/index.js";const trySSR=async(rootPath,requestPath,config,res,log)=>{const htmlPath=requestPath.endsWith("/")?requestPath+"index":requestPath,pagePath=path.join(rootPath,htmlPath.replace(/\.html$/,"")+".page.html");try{await stat(pagePath);const{globals:globals,state:state,maxFragmentDepth:maxFragmentDepth}=config.templating,html=await renderPage(pagePath,rootPath,globals,state,maxFragmentDepth);return res.writeHead(200,{"Content-Type":"text/html; charset=utf-8"}),res.end(html),log(`SSR rendered: ${pagePath}`,2),!0}catch(e){return log(`SSR error for ${requestPath}: ${e.message}`,3),!1}};export default async(files,rootPath,requestPath,method,config,req,res,log,moduleCache=null)=>{if(log(`Attempting to serve: ${requestPath}`,3),config.templating?.ssr&&config.templating?.ssrPriority&&await trySSR(rootPath,requestPath,config,res,log))return!0;const[file,params]=await findFile(files,rootPath,requestPath,method,log);if(!file){if(config.templating?.ssr){if(await trySSR(rootPath,requestPath,config,res,log))return!0;log(`SSR fallback not available for: ${requestPath}`,3)}return log(`No file found for: ${requestPath}`,3),!1}const fileName=path.basename(file);if(log(`Found file: ${file}`,2),fileName.endsWith(".page.html")){log(`Rendering page template: ${fileName}`,2);try{const{globals:globals,state:state,maxFragmentDepth:maxFragmentDepth}=config.templating,html=await renderPage(file,rootPath,globals,state,maxFragmentDepth);return res.writeHead(200,{"Content-Type":"text/html; charset=utf-8"}),res.end(html),!0}catch(error){return log(`Error rendering page template ${fileName}: ${error.message}`,0),res.writeHead(500,{"Content-Type":"text/plain"}),res.end("Internal Server Error"),!0}}if(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),rawBody=await readRawBody(req);return enhancedRequest._rawBody=rawBody,enhancedRequest.body=parseBody(rawBody,req.headers["content-type"]),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/dist/templating/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{readFile,writeFile,mkdir,readdir}from"fs/promises";import path from"path";import{extractAttrs,extractContentBlocks,mergeContentBlocks,replaceLocations,resolveVars,resolveIfs,resolveForeach,resolveFragmentTags}from"./parse.js";import{readFileSync,statSync}from"fs";const findFileUpSync=(filename,startDir,rootDir)=>{let dir=startDir;const root=path.resolve(rootDir);for(;;){const candidate=path.join(dir,filename);try{return statSync(candidate),candidate}catch(e){}if(path.resolve(dir)===root)return null;const parent=path.dirname(dir);if(parent===dir)return null;dir=parent}},loadVersion=rootDir=>{try{return JSON.parse(readFileSync(path.join(rootDir,"package.json"),"utf8")).version||""}catch(e){return""}},walkGlobals=async dir=>{const entries=await readdir(dir,{withFileTypes:!0}),results=[];for(const entry of entries){const full=path.join(dir,entry.name);entry.isDirectory()?results.push(...await walkGlobals(full)):entry.name.endsWith(".global.html")&&results.push(full)}return results},loadGlobalContent=async rootDir=>{const files=await walkGlobals(rootDir),maps=await Promise.all(files.map(async f=>extractContentBlocks(await readFile(f,"utf8"))));return mergeContentBlocks(...maps)},renderPage=async(pageFilePath,rootDir,globals={},state={},maxDepth=10,preloadedGlobalContent=null)=>{const pageContent=await readFile(pageFilePath,"utf8"),pageTagMatch=pageContent.match(/^[\s\S]*?<page((?:[^>"']|"[^"]*"|'[^']*')*)>/);if(!pageTagMatch)throw new Error(`Invalid page file: missing <page> root element in ${pageFilePath}`);const pageAttrs=extractAttrs(pageTagMatch[1]),templateName=pageAttrs.template||"default";delete pageAttrs.template;const pageDir=path.dirname(pageFilePath)
|
|
1
|
+
import{readFile,writeFile,mkdir,readdir}from"fs/promises";import path from"path";import{extractAttrs,extractContentBlocks,mergeContentBlocks,replaceLocations,resolveVars,resolveIfs,resolveForeach,resolveFragmentTags}from"./parse.js";import{readFileSync,statSync}from"fs";const findFileUpSync=(filename,startDir,rootDir)=>{let dir=startDir;const root=path.resolve(rootDir);for(;;){const candidate=path.join(dir,filename);try{return statSync(candidate),candidate}catch(e){}if(path.resolve(dir)===root)return null;const parent=path.dirname(dir);if(parent===dir)return null;dir=parent}},loadVersion=rootDir=>{try{return JSON.parse(readFileSync(path.join(rootDir,"package.json"),"utf8")).version||""}catch(e){return""}},walkGlobals=async dir=>{const entries=await readdir(dir,{withFileTypes:!0}),results=[];for(const entry of entries){const full=path.join(dir,entry.name);entry.isDirectory()?results.push(...await walkGlobals(full)):entry.name.endsWith(".global.html")&&results.push(full)}return results},loadGlobalContent=async rootDir=>{const files=await walkGlobals(rootDir),maps=await Promise.all(files.map(async f=>extractContentBlocks(await readFile(f,"utf8"))));return mergeContentBlocks(...maps)},renderPage=async(pageFilePath,rootDir,globals={},state={},maxDepth=10,preloadedGlobalContent=null)=>{const pageContent=await readFile(pageFilePath,"utf8"),pageTagMatch=pageContent.match(/^[\s\S]*?<page((?:[^>"']|"[^"]*"|'[^']*')*)>/);if(!pageTagMatch)throw new Error(`Invalid page file: missing <page> root element in ${pageFilePath}`);const pageAttrs=extractAttrs(pageTagMatch[1]),templateName=pageAttrs.template||"default";delete pageAttrs.template;const pageDir=path.dirname(pageFilePath);let templateFile=findFileUpSync(`${templateName}.template.html`,pageDir,rootDir);if(templateFile||"default"===templateName||(templateFile=findFileUpSync("default.template.html",pageDir,rootDir)),!templateFile)throw new Error(`Template not found: ${templateName}.template.html or default.template.html (searched from ${pageDir} to ${rootDir})`);const globalContent=preloadedGlobalContent??await loadGlobalContent(rootDir),rawPageBlocks=extractContentBlocks(pageContent),pageBlocks={};for(const[name,entries]of Object.entries(rawPageBlocks))pageBlocks[name]=entries.map(e=>({...e,html:replaceLocations(e.html,globalContent)}));const contentBlocks=mergeContentBlocks(pageBlocks,globalContent);let templateHtml=readFileSync(templateFile,"utf8");templateHtml=resolveFragmentTags(templateHtml,name=>{const filePath=findFileUpSync(name+".fragment.html",pageDir,rootDir);return filePath?readFileSync(filePath,"utf8"):null},0,maxDepth),templateHtml=replaceLocations(templateHtml,contentBlocks);const rel=path.relative(rootDir,path.dirname(pageFilePath)),depth=rel?rel.split(path.sep).length:0,now=new Date,vars={pathToRoot:depth>0?"../".repeat(depth):"./",year:String(now.getFullYear()),date:now.toISOString().slice(0,10),datetime:now.toISOString(),timestamp:String(Date.now()),version:loadVersion(rootDir),env:process.env.NODE_ENV||"",...globals,...state,...pageAttrs};for(const[key,val]of Object.entries(vars))"function"==typeof val&&(vars[key]=val());return templateHtml=resolveIfs(templateHtml,vars),templateHtml=resolveForeach(templateHtml,vars),templateHtml=resolveVars(templateHtml,vars),templateHtml},walkPages=async dir=>{const entries=await readdir(dir,{withFileTypes:!0}),results=[];for(const entry of entries){const full=path.join(dir,entry.name);entry.isDirectory()?results.push(...await walkPages(full)):entry.name.endsWith(".page.html")&&results.push(full)}return results},renderDir=async(inputDir,outputDir,globals={},state={},maxDepth=10)=>{const[pages,globalContent]=await Promise.all([walkPages(inputDir),loadGlobalContent(inputDir)]);let count=0;for(const page of pages){const outRel=path.relative(inputDir,page).replace(/\.page\.html$/,".html"),outPath=path.join(outputDir,outRel);await mkdir(path.dirname(outPath),{recursive:!0});const html=await renderPage(page,inputDir,globals,state,maxDepth,globalContent);await writeFile(outPath,html,"utf8"),count++}return count};export{renderPage,renderDir};
|
package/docs/routing.html
CHANGED
|
@@ -114,10 +114,15 @@
|
|
|
114
114
|
<h2 id="htmlRoutes">HTML Routes</h2>
|
|
115
115
|
<p>Just like JS files, HTML files can be used to define a route. Use <code>GET.html</code>, <code>POST.html</code>, etc... to define files that will be served when that route is requested.</p>
|
|
116
116
|
<pre><code class="hljs markdown">my/<br />├─ route/<br />│ ├─ GET.js<br />│ ├─ POST.js<br />├─ other/<br />│ ├─ route/<br />│ │ ├─ GET.js<br />│ ├─ POST.html<br />│ ├─ GET.html<br /></code></pre>
|
|
117
|
+
|
|
118
|
+
<h2 id="pageHtmlRoutes">Template Page Routes</h2>
|
|
119
|
+
<p>You can use <code>.page.html</code> files as route handlers. These files are rendered through the <a href="templating.html">templating system</a>, so they support templates, fragments, globals, and all other templating features. Use <code>GET.page.html</code>, <code>POST.page.html</code>, etc... to define template-rendered routes for specific methods, or <code>index.page.html</code> as a fallback for all methods.</p>
|
|
120
|
+
<pre><code class="hljs markdown">my/<br />├─ route/<br />│ ├─ GET.js<br />│ ├─ POST.page.html<br />├─ other/<br />│ ├─ GET.page.html<br />│ ├─ index.page.html<br /></code></pre>
|
|
121
|
+
<p><code>.page.html</code> routes are a lower priority than their <code>.js</code> and <code>.html</code> counterparts. If both <code>GET.js</code> and <code>GET.page.html</code> exist in the same directory, <code>GET.js</code> will be used.</p>
|
|
117
122
|
|
|
118
123
|
<h3><code>index</code> fallbacks</h3>
|
|
119
|
-
<p><code>index.js</code> or <code>index.html</code> will be used as a fallback for all routes if a <i>method</i> file is not defined. In the above examples we do not have any routes defined for <code>DELETE</code>, <code>PUT</code> <code>PATCH</code>, etc... so lets use an <code>index.js</code> and <code>index.html</code> to be a "catch-all" for all the methods we have not created handlers for.</p>
|
|
120
|
-
<pre><code class="hljs markdown">my/<br />├─ route/<br />│ ├─ GET.js<br />│ ├─ POST.js<br />│ ├─ index.js<br />├─ other/<br />│ ├─ route/<br />│ │ ├─ GET.js<br />│ │ ├─ index.js<br />│ ├─ POST.html<br />│ ├─ GET.html<br />│ ├─ index.html<br />├─ index.html<br /></code></pre>
|
|
124
|
+
<p><code>index.js</code>, <code>index.html</code>, or <code>index.page.html</code> will be used as a fallback for all routes if a <i>method</i> file is not defined. In the above examples we do not have any routes defined for <code>DELETE</code>, <code>PUT</code> <code>PATCH</code>, etc... so lets use an <code>index.js</code>, <code>index.html</code>, and <code>index.page.html</code> to be a "catch-all" for all the methods we have not created handlers for.</p>
|
|
125
|
+
<pre><code class="hljs markdown">my/<br />├─ route/<br />│ ├─ GET.js<br />│ ├─ POST.js<br />│ ├─ index.js<br />├─ other/<br />│ ├─ route/<br />│ │ ├─ GET.js<br />│ │ ├─ index.js<br />│ ├─ POST.html<br />│ ├─ GET.html<br />│ ├─ index.html<br />├─ index.page.html<br /></code></pre>
|
|
121
126
|
|
|
122
127
|
<h2>Dynamic Routes</h2>
|
|
123
128
|
<p>A dynamic route is a route with a "param" in its path. To define the dynamic parts of the route just wrap the directory name in square brackets. For example if you wanted to get a users profile, or perform CRUD operations on a user you might create the following directory structure.</p>
|
|
@@ -160,13 +165,41 @@
|
|
|
160
165
|
<p>Example static file structure:</p>
|
|
161
166
|
<pre><code class="hljs markdown">public/<br />├─ index.html # Served at /<br />├─ styles.css # Served at /styles.css<br />├─ script.js # Served at /script.js<br />├─ images/<br />│ ├─ logo.png # Served at /images/logo.png<br />├─ api/ # Routes directory<br />│ ├─ hello/GET.js # Route handler<br /></code></pre>
|
|
162
167
|
|
|
168
|
+
<h2 id="catchHandler">Catch Fallback Handler</h2>
|
|
169
|
+
<p>A <code>CATCH</code> file is a special handler that catches requests to any undefined deeper paths within that directory. When a request doesn't match any route or static file, the server walks up the directory tree from the requested path looking for the closest <code>CATCH</code> handler.</p>
|
|
170
|
+
<p>Unlike <code>index</code> files which handle a specific directory, <code>CATCH</code> files are fallbacks for undefined paths below that directory. This makes them perfect for handling REST-style path parameters or serving custom error pages.</p>
|
|
171
|
+
<p>The server checks each directory for the following files in priority order:</p>
|
|
172
|
+
<ol>
|
|
173
|
+
<li><code>CATCH.js</code> — executed as a route module (you control the full response, status code, format)</li>
|
|
174
|
+
<li><code>CATCH.html</code> — served as static HTML with a 404 status code</li>
|
|
175
|
+
<li><code>CATCH.page.html</code> — rendered through the <a href="templating.html">templating system</a> with a 404 status code</li>
|
|
176
|
+
</ol>
|
|
177
|
+
<p>If no <code>CATCH</code> handler is found in the current directory, the server moves up one directory and checks again, repeating until it reaches the server root. This means you can define a root-level <code>CATCH.page.html</code> as a global fallback and override it with more specific handlers in subdirectories.</p>
|
|
178
|
+
<pre><code class="hljs markdown">public/<br />├─ CATCH.page.html # Global fallback for all paths<br />├─ index.html<br />├─ docs/<br />│ ├─ CATCH.page.html # Docs-specific fallback<br />│ ├─ index.html<br />├─ api/<br />│ ├─ CATCH.js # API-specific handler (can return JSON, custom status, etc.)<br />│ ├─ users/<br />│ │ ├─ GET.js<br /></code></pre>
|
|
179
|
+
<p>With this structure:</p>
|
|
180
|
+
<ul>
|
|
181
|
+
<li>A request to <code>/api/users/123/profile</code> (where <code>profile</code> doesn't exist) would use <code>api/CATCH.js</code></li>
|
|
182
|
+
<li>A request to <code>/docs/nonexistent/path</code> would use <code>docs/CATCH.page.html</code></li>
|
|
183
|
+
<li>A request to <code>/other/deep/undefined/path</code> would walk up to <code>CATCH.page.html</code> at the root</li>
|
|
184
|
+
</ul>
|
|
185
|
+
<p>If no <code>CATCH</code> handler is found anywhere in the tree, the server responds with a plain text "Not Found" response.</p>
|
|
186
|
+
|
|
187
|
+
<h3>Using CATCH for Dynamic Route Parameters</h3>
|
|
188
|
+
<p>A common use case is using <code>CATCH.js</code> to handle REST-style path parameters. For example, an <code>api/CATCH.js</code> handler can parse path segments and respond with data or status codes as appropriate:</p>
|
|
189
|
+
<pre><code class="hljs javascript"><span class="hljs-comment">// api/CATCH.js</span><br /><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">request, response</span>) </span>{<br /> <span class="hljs-keyword">const</span> path = request.url.split(<span class="hljs-string">'?'</span>)[<span class="hljs-number">0</span>];<br /> <span class="hljs-keyword">const</span> parts = path.replace(<span class="hljs-string">/\\/api\\//</span>, <span class="hljs-string">''</span>).split(<span class="hljs-string">'/'</span>).filter(Boolean);<br /> <br /> <span class="hljs-comment">// Parse alternating keys and values: /key1/value1/key2/value2</span><br /> <span class="hljs-keyword">const</span> params = {};<br /> <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i < parts.length; i += <span class="hljs-number">2</span>) {<br /> params[parts[i]] = parts[i + <span class="hljs-number">1</span>];<br /> }<br /> <br /> <span class="hljs-keyword">if</span> (Object.keys(params).length === <span class="hljs-number">0</span>) {<br /> response.status(<span class="hljs-number">404</span>).json({error: <span class="hljs-string\">'No parameters provided'</span>});<br /> } <span class="hljs-keyword\">else</span> {<br /> response.json({params, message: <span class="hljs-string\">'Caught by CATCH handler'</span>});<br /> }<br />}</code></pre>
|
|
190
|
+
|
|
163
191
|
<h2>Custom Route Directory Resolution</h2>
|
|
164
192
|
<p>When using <code>customRoutes</code> (see <a href="configuration.html#customRoutes">Configuration</a>), routes that resolve to a directory support full file-based routing. The server checks for route files and index files inside the directory using the same priority order as normal routing:</p>
|
|
165
193
|
<ol>
|
|
166
194
|
<li><code>METHOD.js</code> (e.g. <code>GET.js</code>, <code>POST.js</code>) — executed as a route module</li>
|
|
167
195
|
<li><code>METHOD.html</code> (e.g. <code>GET.html</code>) — served as static</li>
|
|
196
|
+
<li><code>METHOD.page.html</code> (e.g. <code>GET.page.html</code>) — rendered via templating</li>
|
|
168
197
|
<li><code>index.js</code> — executed as a route module</li>
|
|
169
198
|
<li><code>index.html</code> / <code>index.htm</code> — served as static</li>
|
|
199
|
+
<li><code>index.page.html</code> — rendered via templating</li>
|
|
200
|
+
<li><code>CATCH.js</code> — fallback handler for undefined deeper paths</li>
|
|
201
|
+
<li><code>CATCH.html</code> — fallback served as static</li>
|
|
202
|
+
<li><code>CATCH.page.html</code> — fallback rendered via templating</li>
|
|
170
203
|
</ol>
|
|
171
204
|
<p>This applies to both exact and wildcard custom routes. For example, with a wildcard route <code>"/api/**": "../api/**"</code>, a request to <code>/api/auth/session</code> resolves to the <code>../api/auth/session/</code> directory and executes the appropriate route file (e.g. <code>GET.js</code> for GET requests).</p>
|
|
172
205
|
|
|
@@ -14,10 +14,15 @@
|
|
|
14
14
|
<h2 id="htmlRoutes">HTML Routes</h2>
|
|
15
15
|
<p>Just like JS files, HTML files can be used to define a route. Use <code>GET.html</code>, <code>POST.html</code>, etc... to define files that will be served when that route is requested.</p>
|
|
16
16
|
<pre><code class="hljs markdown">my/<br />├─ route/<br />│ ├─ GET.js<br />│ ├─ POST.js<br />├─ other/<br />│ ├─ route/<br />│ │ ├─ GET.js<br />│ ├─ POST.html<br />│ ├─ GET.html<br /></code></pre>
|
|
17
|
+
|
|
18
|
+
<h2 id="pageHtmlRoutes">Template Page Routes</h2>
|
|
19
|
+
<p>You can use <code>.page.html</code> files as route handlers. These files are rendered through the <a href="templating.html">templating system</a>, so they support templates, fragments, globals, and all other templating features. Use <code>GET.page.html</code>, <code>POST.page.html</code>, etc... to define template-rendered routes for specific methods, or <code>index.page.html</code> as a fallback for all methods.</p>
|
|
20
|
+
<pre><code class="hljs markdown">my/<br />├─ route/<br />│ ├─ GET.js<br />│ ├─ POST.page.html<br />├─ other/<br />│ ├─ GET.page.html<br />│ ├─ index.page.html<br /></code></pre>
|
|
21
|
+
<p><code>.page.html</code> routes are a lower priority than their <code>.js</code> and <code>.html</code> counterparts. If both <code>GET.js</code> and <code>GET.page.html</code> exist in the same directory, <code>GET.js</code> will be used.</p>
|
|
17
22
|
|
|
18
23
|
<h3><code>index</code> fallbacks</h3>
|
|
19
|
-
<p><code>index.js</code> or <code>index.html</code> will be used as a fallback for all routes if a <i>method</i> file is not defined. In the above examples we do not have any routes defined for <code>DELETE</code>, <code>PUT</code> <code>PATCH</code>, etc... so lets use an <code>index.js</code> and <code>index.html</code> to be a "catch-all" for all the methods we have not created handlers for.</p>
|
|
20
|
-
<pre><code class="hljs markdown">my/<br />├─ route/<br />│ ├─ GET.js<br />│ ├─ POST.js<br />│ ├─ index.js<br />├─ other/<br />│ ├─ route/<br />│ │ ├─ GET.js<br />│ │ ├─ index.js<br />│ ├─ POST.html<br />│ ├─ GET.html<br />│ ├─ index.html<br />├─ index.html<br /></code></pre>
|
|
24
|
+
<p><code>index.js</code>, <code>index.html</code>, or <code>index.page.html</code> will be used as a fallback for all routes if a <i>method</i> file is not defined. In the above examples we do not have any routes defined for <code>DELETE</code>, <code>PUT</code> <code>PATCH</code>, etc... so lets use an <code>index.js</code>, <code>index.html</code>, and <code>index.page.html</code> to be a "catch-all" for all the methods we have not created handlers for.</p>
|
|
25
|
+
<pre><code class="hljs markdown">my/<br />├─ route/<br />│ ├─ GET.js<br />│ ├─ POST.js<br />│ ├─ index.js<br />├─ other/<br />│ ├─ route/<br />│ │ ├─ GET.js<br />│ │ ├─ index.js<br />│ ├─ POST.html<br />│ ├─ GET.html<br />│ ├─ index.html<br />├─ index.page.html<br /></code></pre>
|
|
21
26
|
|
|
22
27
|
<h2>Dynamic Routes</h2>
|
|
23
28
|
<p>A dynamic route is a route with a "param" in its path. To define the dynamic parts of the route just wrap the directory name in square brackets. For example if you wanted to get a users profile, or perform CRUD operations on a user you might create the following directory structure.</p>
|
|
@@ -60,13 +65,41 @@
|
|
|
60
65
|
<p>Example static file structure:</p>
|
|
61
66
|
<pre><code class="hljs markdown">public/<br />├─ index.html # Served at /<br />├─ styles.css # Served at /styles.css<br />├─ script.js # Served at /script.js<br />├─ images/<br />│ ├─ logo.png # Served at /images/logo.png<br />├─ api/ # Routes directory<br />│ ├─ hello/GET.js # Route handler<br /></code></pre>
|
|
62
67
|
|
|
68
|
+
<h2 id="catchHandler">Catch Fallback Handler</h2>
|
|
69
|
+
<p>A <code>CATCH</code> file is a special handler that catches requests to any undefined deeper paths within that directory. When a request doesn't match any route or static file, the server walks up the directory tree from the requested path looking for the closest <code>CATCH</code> handler.</p>
|
|
70
|
+
<p>Unlike <code>index</code> files which handle a specific directory, <code>CATCH</code> files are fallbacks for undefined paths below that directory. This makes them perfect for handling REST-style path parameters or serving custom error pages.</p>
|
|
71
|
+
<p>The server checks each directory for the following files in priority order:</p>
|
|
72
|
+
<ol>
|
|
73
|
+
<li><code>CATCH.js</code> — executed as a route module (you control the full response, status code, format)</li>
|
|
74
|
+
<li><code>CATCH.html</code> — served as static HTML with a 404 status code</li>
|
|
75
|
+
<li><code>CATCH.page.html</code> — rendered through the <a href="templating.html">templating system</a> with a 404 status code</li>
|
|
76
|
+
</ol>
|
|
77
|
+
<p>If no <code>CATCH</code> handler is found in the current directory, the server moves up one directory and checks again, repeating until it reaches the server root. This means you can define a root-level <code>CATCH.page.html</code> as a global fallback and override it with more specific handlers in subdirectories.</p>
|
|
78
|
+
<pre><code class="hljs markdown">public/<br />├─ CATCH.page.html # Global fallback for all paths<br />├─ index.html<br />├─ docs/<br />│ ├─ CATCH.page.html # Docs-specific fallback<br />│ ├─ index.html<br />├─ api/<br />│ ├─ CATCH.js # API-specific handler (can return JSON, custom status, etc.)<br />│ ├─ users/<br />│ │ ├─ GET.js<br /></code></pre>
|
|
79
|
+
<p>With this structure:</p>
|
|
80
|
+
<ul>
|
|
81
|
+
<li>A request to <code>/api/users/123/profile</code> (where <code>profile</code> doesn't exist) would use <code>api/CATCH.js</code></li>
|
|
82
|
+
<li>A request to <code>/docs/nonexistent/path</code> would use <code>docs/CATCH.page.html</code></li>
|
|
83
|
+
<li>A request to <code>/other/deep/undefined/path</code> would walk up to <code>CATCH.page.html</code> at the root</li>
|
|
84
|
+
</ul>
|
|
85
|
+
<p>If no <code>CATCH</code> handler is found anywhere in the tree, the server responds with a plain text "Not Found" response.</p>
|
|
86
|
+
|
|
87
|
+
<h3>Using CATCH for Dynamic Route Parameters</h3>
|
|
88
|
+
<p>A common use case is using <code>CATCH.js</code> to handle REST-style path parameters. For example, an <code>api/CATCH.js</code> handler can parse path segments and respond with data or status codes as appropriate:</p>
|
|
89
|
+
<pre><code class="hljs javascript"><span class="hljs-comment">// api/CATCH.js</span><br /><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">request, response</span>) </span>{<br /> <span class="hljs-keyword">const</span> path = request.url.split(<span class="hljs-string">'?'</span>)[<span class="hljs-number">0</span>];<br /> <span class="hljs-keyword">const</span> parts = path.replace(<span class="hljs-string">/\\/api\\//</span>, <span class="hljs-string">''</span>).split(<span class="hljs-string">'/'</span>).filter(Boolean);<br /> <br /> <span class="hljs-comment">// Parse alternating keys and values: /key1/value1/key2/value2</span><br /> <span class="hljs-keyword">const</span> params = {};<br /> <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i < parts.length; i += <span class="hljs-number">2</span>) {<br /> params[parts[i]] = parts[i + <span class="hljs-number">1</span>];<br /> }<br /> <br /> <span class="hljs-keyword">if</span> (Object.keys(params).length === <span class="hljs-number">0</span>) {<br /> response.status(<span class="hljs-number">404</span>).json({error: <span class="hljs-string\">'No parameters provided'</span>});<br /> } <span class="hljs-keyword\">else</span> {<br /> response.json({params, message: <span class="hljs-string\">'Caught by CATCH handler'</span>});<br /> }<br />}</code></pre>
|
|
90
|
+
|
|
63
91
|
<h2>Custom Route Directory Resolution</h2>
|
|
64
92
|
<p>When using <code>customRoutes</code> (see <a href="configuration.html#customRoutes">Configuration</a>), routes that resolve to a directory support full file-based routing. The server checks for route files and index files inside the directory using the same priority order as normal routing:</p>
|
|
65
93
|
<ol>
|
|
66
94
|
<li><code>METHOD.js</code> (e.g. <code>GET.js</code>, <code>POST.js</code>) — executed as a route module</li>
|
|
67
95
|
<li><code>METHOD.html</code> (e.g. <code>GET.html</code>) — served as static</li>
|
|
96
|
+
<li><code>METHOD.page.html</code> (e.g. <code>GET.page.html</code>) — rendered via templating</li>
|
|
68
97
|
<li><code>index.js</code> — executed as a route module</li>
|
|
69
98
|
<li><code>index.html</code> / <code>index.htm</code> — served as static</li>
|
|
99
|
+
<li><code>index.page.html</code> — rendered via templating</li>
|
|
100
|
+
<li><code>CATCH.js</code> — fallback handler for undefined deeper paths</li>
|
|
101
|
+
<li><code>CATCH.html</code> — fallback served as static</li>
|
|
102
|
+
<li><code>CATCH.page.html</code> — fallback rendered via templating</li>
|
|
70
103
|
</ol>
|
|
71
104
|
<p>This applies to both exact and wildcard custom routes. For example, with a wildcard route <code>"/api/**": "../api/**"</code>, a request to <code>/api/auth/session</code> resolves to the <code>../api/auth/session/</code> directory and executes the appropriate route file (e.g. <code>GET.js</code> for GET requests).</p>
|
|
72
105
|
</content>
|
package/package.json
CHANGED
package/src/defaultConfig.js
CHANGED
|
@@ -62,8 +62,7 @@ export default {
|
|
|
62
62
|
"package\\.json$",
|
|
63
63
|
"package-lock\\.json$",
|
|
64
64
|
"\\.template\\.html$",
|
|
65
|
-
"\\.fragment\\.html$"
|
|
66
|
-
"\\.page\\.html$"
|
|
65
|
+
"\\.fragment\\.html$"
|
|
67
66
|
],
|
|
68
67
|
routeFiles: [
|
|
69
68
|
'GET.js',
|
|
@@ -75,7 +74,19 @@ export default {
|
|
|
75
74
|
'PATCH.js',
|
|
76
75
|
'CONNECT.js',
|
|
77
76
|
'TRACE.js',
|
|
78
|
-
'index.js'
|
|
77
|
+
'index.js',
|
|
78
|
+
'GET.page.html',
|
|
79
|
+
'POST.page.html',
|
|
80
|
+
'PUT.page.html',
|
|
81
|
+
'DELETE.page.html',
|
|
82
|
+
'HEAD.page.html',
|
|
83
|
+
'OPTIONS.page.html',
|
|
84
|
+
'PATCH.page.html',
|
|
85
|
+
'CONNECT.page.html',
|
|
86
|
+
'TRACE.page.html',
|
|
87
|
+
'index.page.html',
|
|
88
|
+
'CATCH.js',
|
|
89
|
+
'CATCH.page.html'
|
|
79
90
|
],
|
|
80
91
|
noRescanPaths: [
|
|
81
92
|
"^/\\.well-known/",
|
package/src/findFile.js
CHANGED
|
@@ -45,12 +45,17 @@ export default (files, rootPath, requestPath, method, log) => {
|
|
|
45
45
|
const dirPath = normalizeRequestPath || '';
|
|
46
46
|
const methodUpper = method.toUpperCase();
|
|
47
47
|
|
|
48
|
-
// Priority order: METHOD.js, METHOD.html, index.js, index.html
|
|
48
|
+
// Priority order: METHOD.js, METHOD.html, METHOD.page.html, index.js, index.html, index.page.html
|
|
49
49
|
const indexFiles = [
|
|
50
50
|
`${methodUpper}.js`,
|
|
51
51
|
`${methodUpper}.html`,
|
|
52
|
+
`${methodUpper}.page.html`,
|
|
52
53
|
'index.js',
|
|
53
|
-
'index.html'
|
|
54
|
+
'index.html',
|
|
55
|
+
'index.page.html',
|
|
56
|
+
'CATCH.js',
|
|
57
|
+
'CATCH.html',
|
|
58
|
+
'CATCH.page.html'
|
|
54
59
|
];
|
|
55
60
|
|
|
56
61
|
log(`Looking for index files: [${indexFiles.join(', ')}]`, 3);
|
|
@@ -116,8 +121,13 @@ export default (files, rootPath, requestPath, method, log) => {
|
|
|
116
121
|
const indexFiles = [
|
|
117
122
|
`${methodUpper}.js`,
|
|
118
123
|
`${methodUpper}.html`,
|
|
124
|
+
`${methodUpper}.page.html`,
|
|
119
125
|
'index.js',
|
|
120
|
-
'index.html'
|
|
126
|
+
'index.html',
|
|
127
|
+
'index.page.html',
|
|
128
|
+
'CATCH.js',
|
|
129
|
+
'CATCH.html',
|
|
130
|
+
'CATCH.page.html'
|
|
121
131
|
];
|
|
122
132
|
|
|
123
133
|
const priority = indexFiles.indexOf(fileName);
|
package/src/router.js
CHANGED
|
@@ -403,13 +403,26 @@ export default async (flags, log) => {
|
|
|
403
403
|
const candidates = [
|
|
404
404
|
`${methodUpper}.js`,
|
|
405
405
|
`${methodUpper}.html`,
|
|
406
|
+
`${methodUpper}.page.html`,
|
|
406
407
|
'index.js',
|
|
407
408
|
'index.html',
|
|
408
|
-
'index.
|
|
409
|
+
'index.page.html',
|
|
410
|
+
'index.htm',
|
|
411
|
+
'CATCH.js',
|
|
412
|
+
'CATCH.html',
|
|
413
|
+
'CATCH.page.html'
|
|
409
414
|
];
|
|
410
415
|
for(const candidate of candidates) {
|
|
411
416
|
const candidatePath = path.join(filePath, candidate);
|
|
412
417
|
try { await stat(candidatePath); } catch { continue; }
|
|
418
|
+
if(candidate.endsWith('.page.html')) {
|
|
419
|
+
log(`Rendering page template: ${candidatePath}`, 2);
|
|
420
|
+
const {globals, state, maxFragmentDepth} = config.templating;
|
|
421
|
+
const html = await renderPage(candidatePath, rootPath, globals, state, maxFragmentDepth);
|
|
422
|
+
res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
|
|
423
|
+
res.end(html);
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
413
426
|
if(config.routeFiles.includes(candidate)) {
|
|
414
427
|
log(`Executing route file: ${candidatePath}`, 2);
|
|
415
428
|
await executeRouteModule(candidatePath, req, res, params);
|
|
@@ -423,6 +436,14 @@ export default async (flags, log) => {
|
|
|
423
436
|
}
|
|
424
437
|
|
|
425
438
|
const fileName = path.basename(filePath);
|
|
439
|
+
if(fileName.endsWith('.page.html')) {
|
|
440
|
+
log(`Rendering page template: ${filePath}`, 2);
|
|
441
|
+
const {globals, state, maxFragmentDepth} = config.templating;
|
|
442
|
+
const html = await renderPage(filePath, rootPath, globals, state, maxFragmentDepth);
|
|
443
|
+
res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
|
|
444
|
+
res.end(html);
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
426
447
|
if(config.routeFiles.includes(fileName)) {
|
|
427
448
|
log(`Executing route file: ${filePath}`, 2);
|
|
428
449
|
await executeRouteModule(filePath, req, res, params);
|
|
@@ -491,6 +512,43 @@ export default async (flags, log) => {
|
|
|
491
512
|
|
|
492
513
|
// Track 404 attempts to avoid unnecessary rescans
|
|
493
514
|
const rescanAttempts = new Map(); // path -> attempt count
|
|
515
|
+
|
|
516
|
+
// Walk up the directory tree from requestPath looking for CATCH.js, CATCH.html, CATCH.page.html.
|
|
517
|
+
// Returns true if a catch fallback handler was found and served, false otherwise.
|
|
518
|
+
const serveCatchFallback = async (requestPath, req, res) => {
|
|
519
|
+
const candidates = ['CATCH.js', 'CATCH.html', 'CATCH.page.html'];
|
|
520
|
+
let dir = path.join(rootPath, requestPath.startsWith('/') ? requestPath.slice(1) : requestPath);
|
|
521
|
+
// Start from the requested directory (or its parent if it's a file path)
|
|
522
|
+
if(path.extname(dir)) dir = path.dirname(dir);
|
|
523
|
+
|
|
524
|
+
while(dir.startsWith(rootPath)) {
|
|
525
|
+
for(const candidate of candidates) {
|
|
526
|
+
const candidatePath = path.join(dir, candidate);
|
|
527
|
+
try { await stat(candidatePath); } catch { continue; }
|
|
528
|
+
log(`Serving catch fallback: ${candidatePath}`, 2);
|
|
529
|
+
if(candidate === 'CATCH.page.html') {
|
|
530
|
+
const {globals, state, maxFragmentDepth} = config.templating;
|
|
531
|
+
const html = await renderPage(candidatePath, rootPath, globals, state, maxFragmentDepth);
|
|
532
|
+
res.writeHead(404, {'Content-Type': 'text/html; charset=utf-8'});
|
|
533
|
+
res.end(html);
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
if(candidate === 'CATCH.js') {
|
|
537
|
+
await executeRouteModule(candidatePath, req, res);
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
// CATCH.html — serve static with 404 status
|
|
541
|
+
const fileContent = await readFile(candidatePath, 'utf8');
|
|
542
|
+
res.writeHead(404, {'Content-Type': 'text/html; charset=utf-8'});
|
|
543
|
+
res.end(fileContent);
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
const parent = path.dirname(dir);
|
|
547
|
+
if(parent === dir) break;
|
|
548
|
+
dir = parent;
|
|
549
|
+
}
|
|
550
|
+
return false;
|
|
551
|
+
};
|
|
494
552
|
const dynamicNoRescanPaths = new Set(); // paths that have exceeded max attempts
|
|
495
553
|
|
|
496
554
|
// Helper function to check if a path should skip rescanning
|
|
@@ -657,6 +715,7 @@ export default async (flags, log) => {
|
|
|
657
715
|
if (!reserved) {
|
|
658
716
|
trackRescanAttempt(requestPath);
|
|
659
717
|
log(`404 - File not found after rescan: ${requestPath}`, 1);
|
|
718
|
+
if(await serveCatchFallback(requestPath, req, res)) return;
|
|
660
719
|
enhancedResponse.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
661
720
|
enhancedResponse.end('Not Found');
|
|
662
721
|
} else {
|
|
@@ -669,6 +728,7 @@ export default async (flags, log) => {
|
|
|
669
728
|
} else {
|
|
670
729
|
log(`404 - File not found: ${requestPath}`, 1);
|
|
671
730
|
}
|
|
731
|
+
if(await serveCatchFallback(requestPath, req, res)) return;
|
|
672
732
|
enhancedResponse.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
673
733
|
enhancedResponse.end('Not Found');
|
|
674
734
|
}
|
package/src/serveFile.js
CHANGED
|
@@ -44,6 +44,22 @@ export default async (files, rootPath, requestPath, method, config, req, res, lo
|
|
|
44
44
|
const fileName = path.basename(file);
|
|
45
45
|
log(`Found file: ${file}`, 2);
|
|
46
46
|
|
|
47
|
+
if(fileName.endsWith('.page.html')) {
|
|
48
|
+
log(`Rendering page template: ${fileName}`, 2);
|
|
49
|
+
try {
|
|
50
|
+
const {globals, state, maxFragmentDepth} = config.templating;
|
|
51
|
+
const html = await renderPage(file, rootPath, globals, state, maxFragmentDepth);
|
|
52
|
+
res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
|
|
53
|
+
res.end(html);
|
|
54
|
+
return true;
|
|
55
|
+
} catch(error) {
|
|
56
|
+
log(`Error rendering page template ${fileName}: ${error.message}`, 0);
|
|
57
|
+
res.writeHead(500, {'Content-Type': 'text/plain'});
|
|
58
|
+
res.end('Internal Server Error');
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
47
63
|
// Check if this is a route file that should be executed as a module
|
|
48
64
|
if (config.routeFiles.includes(fileName)) {
|
|
49
65
|
log(`Executing route file: ${fileName}`, 2);
|
package/src/templating/index.js
CHANGED
|
@@ -74,8 +74,14 @@ const renderPage = async (pageFilePath, rootDir, globals = {}, state = {}, maxDe
|
|
|
74
74
|
delete pageAttrs.template;
|
|
75
75
|
|
|
76
76
|
const pageDir = path.dirname(pageFilePath);
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
let templateFile = findFileUpSync(`${templateName}.template.html`, pageDir, rootDir);
|
|
78
|
+
|
|
79
|
+
// If the specified template is not found, fall back to default.template.html
|
|
80
|
+
if (!templateFile && templateName !== 'default') {
|
|
81
|
+
templateFile = findFileUpSync('default.template.html', pageDir, rootDir);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if(!templateFile) throw new Error(`Template not found: ${templateName}.template.html or default.template.html (searched from ${pageDir} to ${rootDir})`);
|
|
79
85
|
|
|
80
86
|
const globalContent = preloadedGlobalContent ?? await loadGlobalContent(rootDir);
|
|
81
87
|
const rawPageBlocks = extractContentBlocks(pageContent);
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import path from 'path';
|
|
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 router from '../src/router.js';
|
|
8
|
+
|
|
9
|
+
const template = '<html><body><location name="main" /></body></html>';
|
|
10
|
+
const noop = () => {};
|
|
11
|
+
|
|
12
|
+
const startServer = async (dir, flags, config = {}) => {
|
|
13
|
+
await write(dir, `${flags.root}/.config.json`, JSON.stringify(config));
|
|
14
|
+
const handler = await router({...flags, logging: 0}, noop);
|
|
15
|
+
const server = http.createServer(handler);
|
|
16
|
+
const port = randomPort();
|
|
17
|
+
await new Promise(r => server.listen(port, r));
|
|
18
|
+
await new Promise(r => setTimeout(r, 30));
|
|
19
|
+
return {server, port};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
'CATCH.html in same directory serves with 404 status': async ({pass, fail}) => {
|
|
24
|
+
try {
|
|
25
|
+
await withTempDir(async (dir) => {
|
|
26
|
+
await write(dir, 'public/index.html', '<h1>root</h1>');
|
|
27
|
+
await write(dir, 'public/CATCH.html', '<h1>Not Found</h1>');
|
|
28
|
+
|
|
29
|
+
const prev = process.cwd();
|
|
30
|
+
process.chdir(dir);
|
|
31
|
+
const {server, port} = await startServer(dir, {root: 'public'});
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const r = await httpGet(`http://localhost:${port}/nonexistent`);
|
|
35
|
+
if(r.res.statusCode !== 404) throw new Error(`expected 404, got ${r.res.statusCode}`);
|
|
36
|
+
const body = r.body.toString();
|
|
37
|
+
if(!body.includes('<h1>Not Found</h1>')) throw new Error(`missing content: ${body}`);
|
|
38
|
+
} finally {
|
|
39
|
+
server.close();
|
|
40
|
+
process.chdir(prev);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
pass();
|
|
44
|
+
} catch(e) {
|
|
45
|
+
fail(e.message);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
'CATCH.page.html renders through templating with 404 status': async ({pass, fail}) => {
|
|
50
|
+
try {
|
|
51
|
+
await withTempDir(async (dir) => {
|
|
52
|
+
const page = '<page template="default"><content location="main"><h1>Custom Catch</h1></content></page>';
|
|
53
|
+
await write(dir, 'public/default.template.html', template);
|
|
54
|
+
await write(dir, 'public/CATCH.page.html', page);
|
|
55
|
+
await write(dir, 'public/index.html', '<h1>root</h1>');
|
|
56
|
+
|
|
57
|
+
const prev = process.cwd();
|
|
58
|
+
process.chdir(dir);
|
|
59
|
+
const {server, port} = await startServer(dir, {root: 'public'});
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const r = await httpGet(`http://localhost:${port}/nonexistent`);
|
|
63
|
+
if(r.res.statusCode !== 404) throw new Error(`expected 404, got ${r.res.statusCode}`);
|
|
64
|
+
const body = r.body.toString();
|
|
65
|
+
if(!body.includes('<h1>Custom Catch</h1>')) throw new Error(`missing page content: ${body}`);
|
|
66
|
+
if(!body.includes('<html>')) throw new Error(`missing template wrapper: ${body}`);
|
|
67
|
+
} finally {
|
|
68
|
+
server.close();
|
|
69
|
+
process.chdir(prev);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
pass();
|
|
73
|
+
} catch(e) {
|
|
74
|
+
fail(e.message);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
'CATCH.js executes as route module': async ({pass, fail}) => {
|
|
79
|
+
try {
|
|
80
|
+
await withTempDir(async (dir) => {
|
|
81
|
+
const handler = 'export default async (req, res) => { res.writeHead(404, {"Content-Type":"application/json"}); res.end(JSON.stringify({error: "catch handler", path: req.url})); };';
|
|
82
|
+
await write(dir, 'public/CATCH.js', handler);
|
|
83
|
+
await write(dir, 'public/index.html', '<h1>root</h1>');
|
|
84
|
+
|
|
85
|
+
const prev = process.cwd();
|
|
86
|
+
process.chdir(dir);
|
|
87
|
+
const {server, port} = await startServer(dir, {root: 'public'});
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const r = await httpGet(`http://localhost:${port}/nonexistent`);
|
|
91
|
+
const body = r.body.toString();
|
|
92
|
+
const json = JSON.parse(body);
|
|
93
|
+
if(json.error !== 'catch handler') throw new Error(`unexpected response: ${body}`);
|
|
94
|
+
} finally {
|
|
95
|
+
server.close();
|
|
96
|
+
process.chdir(prev);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
pass();
|
|
100
|
+
} catch(e) {
|
|
101
|
+
fail(e.message);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
'CATCH fallback walks up directory tree to find closest handler': async ({pass, fail}) => {
|
|
106
|
+
try {
|
|
107
|
+
await withTempDir(async (dir) => {
|
|
108
|
+
await write(dir, 'public/index.html', '<h1>root</h1>');
|
|
109
|
+
await write(dir, 'public/CATCH.html', '<h1>Root Catch</h1>');
|
|
110
|
+
await write(dir, 'public/deep/CATCH.html', '<h1>Deep Catch</h1>');
|
|
111
|
+
|
|
112
|
+
const prev = process.cwd();
|
|
113
|
+
process.chdir(dir);
|
|
114
|
+
const {server, port} = await startServer(dir, {root: 'public'});
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
// Request under /deep/ should find deep/CATCH.html
|
|
118
|
+
const r1 = await httpGet(`http://localhost:${port}/deep/nonexistent`);
|
|
119
|
+
if(r1.res.statusCode !== 404) throw new Error(`expected 404, got ${r1.res.statusCode}`);
|
|
120
|
+
const body1 = r1.body.toString();
|
|
121
|
+
if(!body1.includes('<h1>Deep Catch</h1>')) throw new Error(`expected deep catch, got: ${body1}`);
|
|
122
|
+
|
|
123
|
+
// Request at root level should find root CATCH.html
|
|
124
|
+
const r2 = await httpGet(`http://localhost:${port}/nonexistent`);
|
|
125
|
+
if(r2.res.statusCode !== 404) throw new Error(`expected 404, got ${r2.res.statusCode}`);
|
|
126
|
+
const body2 = r2.body.toString();
|
|
127
|
+
if(!body2.includes('<h1>Root Catch</h1>')) throw new Error(`expected root catch, got: ${body2}`);
|
|
128
|
+
} finally {
|
|
129
|
+
server.close();
|
|
130
|
+
process.chdir(prev);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
pass();
|
|
134
|
+
} catch(e) {
|
|
135
|
+
fail(e.message);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
'CATCH fallback walks up multiple levels': async ({pass, fail}) => {
|
|
140
|
+
try {
|
|
141
|
+
await withTempDir(async (dir) => {
|
|
142
|
+
await write(dir, 'public/index.html', '<h1>root</h1>');
|
|
143
|
+
await write(dir, 'public/CATCH.html', '<h1>Root Catch</h1>');
|
|
144
|
+
|
|
145
|
+
const prev = process.cwd();
|
|
146
|
+
process.chdir(dir);
|
|
147
|
+
const {server, port} = await startServer(dir, {root: 'public'});
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
// Deep nested request should walk up to root CATCH.html
|
|
151
|
+
const r = await httpGet(`http://localhost:${port}/a/b/c/d/nonexistent`);
|
|
152
|
+
if(r.res.statusCode !== 404) throw new Error(`expected 404, got ${r.res.statusCode}`);
|
|
153
|
+
const body = r.body.toString();
|
|
154
|
+
if(!body.includes('<h1>Root Catch</h1>')) throw new Error(`expected root catch, got: ${body}`);
|
|
155
|
+
} finally {
|
|
156
|
+
server.close();
|
|
157
|
+
process.chdir(prev);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
pass();
|
|
161
|
+
} catch(e) {
|
|
162
|
+
fail(e.message);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
'CATCH.js takes priority over CATCH.html': async ({pass, fail}) => {
|
|
167
|
+
try {
|
|
168
|
+
await withTempDir(async (dir) => {
|
|
169
|
+
const handler = 'export default async (req, res) => { res.writeHead(404, {"Content-Type":"text/plain"}); res.end("js-catch"); };';
|
|
170
|
+
await write(dir, 'public/CATCH.js', handler);
|
|
171
|
+
await write(dir, 'public/CATCH.html', '<h1>HTML Catch</h1>');
|
|
172
|
+
await write(dir, 'public/index.html', '<h1>root</h1>');
|
|
173
|
+
|
|
174
|
+
const prev = process.cwd();
|
|
175
|
+
process.chdir(dir);
|
|
176
|
+
const {server, port} = await startServer(dir, {root: 'public'});
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const r = await httpGet(`http://localhost:${port}/nonexistent`);
|
|
180
|
+
const body = r.body.toString();
|
|
181
|
+
if(body !== 'js-catch') throw new Error(`expected js catch, got: ${body}`);
|
|
182
|
+
} finally {
|
|
183
|
+
server.close();
|
|
184
|
+
process.chdir(prev);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
pass();
|
|
188
|
+
} catch(e) {
|
|
189
|
+
fail(e.message);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
'CATCH.html takes priority over CATCH.page.html': async ({pass, fail}) => {
|
|
194
|
+
try {
|
|
195
|
+
await withTempDir(async (dir) => {
|
|
196
|
+
const page = '<page template="default"><content location="main"><h1>Page Catch</h1></content></page>';
|
|
197
|
+
await write(dir, 'public/default.template.html', template);
|
|
198
|
+
await write(dir, 'public/CATCH.html', '<h1>HTML Catch</h1>');
|
|
199
|
+
await write(dir, 'public/CATCH.page.html', page);
|
|
200
|
+
await write(dir, 'public/index.html', '<h1>root</h1>');
|
|
201
|
+
|
|
202
|
+
const prev = process.cwd();
|
|
203
|
+
process.chdir(dir);
|
|
204
|
+
const {server, port} = await startServer(dir, {root: 'public'});
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const r = await httpGet(`http://localhost:${port}/nonexistent`);
|
|
208
|
+
if(r.res.statusCode !== 404) throw new Error(`expected 404, got ${r.res.statusCode}`);
|
|
209
|
+
const body = r.body.toString();
|
|
210
|
+
if(!body.includes('<h1>HTML Catch</h1>')) throw new Error(`expected html catch, got: ${body}`);
|
|
211
|
+
} finally {
|
|
212
|
+
server.close();
|
|
213
|
+
process.chdir(prev);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
pass();
|
|
217
|
+
} catch(e) {
|
|
218
|
+
fail(e.message);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
'no CATCH fallback returns plain text Not Found': async ({pass, fail}) => {
|
|
223
|
+
try {
|
|
224
|
+
await withTempDir(async (dir) => {
|
|
225
|
+
await write(dir, 'public/index.html', '<h1>root</h1>');
|
|
226
|
+
|
|
227
|
+
const prev = process.cwd();
|
|
228
|
+
process.chdir(dir);
|
|
229
|
+
const {server, port} = await startServer(dir, {root: 'public'});
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const r = await httpGet(`http://localhost:${port}/nonexistent`);
|
|
233
|
+
if(r.res.statusCode !== 404) throw new Error(`expected 404, got ${r.res.statusCode}`);
|
|
234
|
+
const body = r.body.toString();
|
|
235
|
+
if(body !== 'Not Found') throw new Error(`expected plain 'Not Found', got: ${body}`);
|
|
236
|
+
} finally {
|
|
237
|
+
server.close();
|
|
238
|
+
process.chdir(prev);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
pass();
|
|
242
|
+
} catch(e) {
|
|
243
|
+
fail(e.message);
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
'CATCH can respond with any status code (not just 404)': async ({pass, fail}) => {
|
|
248
|
+
try {
|
|
249
|
+
await withTempDir(async (dir) => {
|
|
250
|
+
const handler = 'export default async (req, res) => { res.writeHead(200, {"Content-Type":"application/json"}); res.end(JSON.stringify({message: "catch all handler", path: req.url})); };';
|
|
251
|
+
await write(dir, 'public/CATCH.js', handler);
|
|
252
|
+
await write(dir, 'public/index.html', '<h1>root</h1>');
|
|
253
|
+
|
|
254
|
+
const prev = process.cwd();
|
|
255
|
+
process.chdir(dir);
|
|
256
|
+
const {server, port} = await startServer(dir, {root: 'public'});
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const r = await httpGet(`http://localhost:${port}/api/data/key/value`);
|
|
260
|
+
if(r.res.statusCode !== 200) throw new Error(`expected 200, got ${r.res.statusCode}`);
|
|
261
|
+
const body = r.body.toString();
|
|
262
|
+
const json = JSON.parse(body);
|
|
263
|
+
if(json.message !== 'catch all handler') throw new Error(`unexpected response: ${body}`);
|
|
264
|
+
if(!json.path.includes('key/value')) throw new Error(`path params not captured: ${body}`);
|
|
265
|
+
} finally {
|
|
266
|
+
server.close();
|
|
267
|
+
process.chdir(prev);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
pass();
|
|
271
|
+
} catch(e) {
|
|
272
|
+
fail(e.message);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import path from 'path';
|
|
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 router from '../src/router.js';
|
|
8
|
+
import findFile from '../src/findFile.js';
|
|
9
|
+
|
|
10
|
+
const template = '<html><body><location name="main" /></body></html>';
|
|
11
|
+
const noop = () => {};
|
|
12
|
+
|
|
13
|
+
const startServer = async (dir, flags, config = {}) => {
|
|
14
|
+
await write(dir, `${flags.root}/.config.json`, JSON.stringify(config));
|
|
15
|
+
const handler = await router({...flags, logging: 0}, noop);
|
|
16
|
+
const server = http.createServer(handler);
|
|
17
|
+
const port = randomPort();
|
|
18
|
+
await new Promise(r => server.listen(port, r));
|
|
19
|
+
await new Promise(r => setTimeout(r, 30));
|
|
20
|
+
return {server, port};
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const toAbs = (root, p) => path.join(root, p);
|
|
24
|
+
|
|
25
|
+
export default {
|
|
26
|
+
'findFile resolves METHOD.page.html after METHOD.html': async ({pass, fail}) => {
|
|
27
|
+
const root = path.join(process.cwd(), 'tmp-root');
|
|
28
|
+
const files = [
|
|
29
|
+
toAbs(root, 'docs/GET.page.html'),
|
|
30
|
+
toAbs(root, 'docs/index.html')
|
|
31
|
+
];
|
|
32
|
+
const [file] = await findFile(files, root, '/docs', 'GET', noop);
|
|
33
|
+
if(!file || path.basename(file) !== 'GET.page.html') return fail(`expected GET.page.html, got ${file ? path.basename(file) : 'none'}`);
|
|
34
|
+
pass();
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
'findFile prefers METHOD.js over METHOD.page.html': async ({pass, fail}) => {
|
|
38
|
+
const root = path.join(process.cwd(), 'tmp-root');
|
|
39
|
+
const files = [
|
|
40
|
+
toAbs(root, 'api/GET.js'),
|
|
41
|
+
toAbs(root, 'api/GET.page.html')
|
|
42
|
+
];
|
|
43
|
+
const [file] = await findFile(files, root, '/api', 'GET', noop);
|
|
44
|
+
if(!file || path.basename(file) !== 'GET.js') return fail(`expected GET.js, got ${file ? path.basename(file) : 'none'}`);
|
|
45
|
+
pass();
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
'findFile prefers METHOD.html over METHOD.page.html': async ({pass, fail}) => {
|
|
49
|
+
const root = path.join(process.cwd(), 'tmp-root');
|
|
50
|
+
const files = [
|
|
51
|
+
toAbs(root, 'info/GET.html'),
|
|
52
|
+
toAbs(root, 'info/GET.page.html')
|
|
53
|
+
];
|
|
54
|
+
const [file] = await findFile(files, root, '/info', 'GET', noop);
|
|
55
|
+
if(!file || path.basename(file) !== 'GET.html') return fail(`expected GET.html, got ${file ? path.basename(file) : 'none'}`);
|
|
56
|
+
pass();
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
'findFile resolves index.page.html': async ({pass, fail}) => {
|
|
60
|
+
const root = path.join(process.cwd(), 'tmp-root');
|
|
61
|
+
const files = [toAbs(root, 'section/index.page.html')];
|
|
62
|
+
const [file] = await findFile(files, root, '/section', 'GET', noop);
|
|
63
|
+
if(!file || path.basename(file) !== 'index.page.html') return fail(`expected index.page.html, got ${file ? path.basename(file) : 'none'}`);
|
|
64
|
+
pass();
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
'findFile prefers index.js over index.page.html': async ({pass, fail}) => {
|
|
68
|
+
const root = path.join(process.cwd(), 'tmp-root');
|
|
69
|
+
const files = [
|
|
70
|
+
toAbs(root, 'section/index.js'),
|
|
71
|
+
toAbs(root, 'section/index.page.html')
|
|
72
|
+
];
|
|
73
|
+
const [file] = await findFile(files, root, '/section', 'GET', noop);
|
|
74
|
+
if(!file || path.basename(file) !== 'index.js') return fail(`expected index.js, got ${file ? path.basename(file) : 'none'}`);
|
|
75
|
+
pass();
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
'findFile resolves dynamic route with METHOD.page.html': async ({pass, fail}) => {
|
|
79
|
+
const root = path.join(process.cwd(), 'tmp-root');
|
|
80
|
+
const files = [toAbs(root, 'users/[id]/GET.page.html')];
|
|
81
|
+
const [file, params] = await findFile(files, root, '/users/42', 'GET', noop);
|
|
82
|
+
if(!file || path.basename(file) !== 'GET.page.html') return fail(`expected GET.page.html, got ${file ? path.basename(file) : 'none'}`);
|
|
83
|
+
if(params.id !== '42') return fail(`expected id=42, got ${params.id}`);
|
|
84
|
+
pass();
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
'router serves GET.page.html rendered through templating': async ({pass, fail}) => {
|
|
88
|
+
try {
|
|
89
|
+
await withTempDir(async (dir) => {
|
|
90
|
+
const page = '<page template="default"><content location="main"><h1>Page Route</h1></content></page>';
|
|
91
|
+
await write(dir, 'public/default.template.html', template);
|
|
92
|
+
await write(dir, 'public/api/GET.page.html', page);
|
|
93
|
+
|
|
94
|
+
const prev = process.cwd();
|
|
95
|
+
process.chdir(dir);
|
|
96
|
+
const {server, port} = await startServer(dir, {root: 'public'});
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const r = await httpGet(`http://localhost:${port}/api`);
|
|
100
|
+
if(r.res.statusCode !== 200) throw new Error(`expected 200, got ${r.res.statusCode}`);
|
|
101
|
+
const body = r.body.toString();
|
|
102
|
+
if(!body.includes('<h1>Page Route</h1>')) throw new Error(`missing page content: ${body}`);
|
|
103
|
+
if(!body.includes('<html>')) throw new Error(`missing template wrapper: ${body}`);
|
|
104
|
+
} finally {
|
|
105
|
+
server.close();
|
|
106
|
+
process.chdir(prev);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
pass();
|
|
110
|
+
} catch(e) {
|
|
111
|
+
fail(e.message);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
'router serves index.page.html for directory request': async ({pass, fail}) => {
|
|
116
|
+
try {
|
|
117
|
+
await withTempDir(async (dir) => {
|
|
118
|
+
const page = '<page template="default"><content location="main"><h1>Dir Index</h1></content></page>';
|
|
119
|
+
await write(dir, 'public/default.template.html', template);
|
|
120
|
+
await write(dir, 'public/section/index.page.html', page);
|
|
121
|
+
|
|
122
|
+
const prev = process.cwd();
|
|
123
|
+
process.chdir(dir);
|
|
124
|
+
const {server, port} = await startServer(dir, {root: 'public'});
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const r = await httpGet(`http://localhost:${port}/section`);
|
|
128
|
+
if(r.res.statusCode !== 200) throw new Error(`expected 200, got ${r.res.statusCode}`);
|
|
129
|
+
const body = r.body.toString();
|
|
130
|
+
if(!body.includes('<h1>Dir Index</h1>')) throw new Error(`missing page content: ${body}`);
|
|
131
|
+
} finally {
|
|
132
|
+
server.close();
|
|
133
|
+
process.chdir(prev);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
pass();
|
|
137
|
+
} catch(e) {
|
|
138
|
+
fail(e.message);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
'router prefers GET.js over GET.page.html': async ({pass, fail}) => {
|
|
143
|
+
try {
|
|
144
|
+
await withTempDir(async (dir) => {
|
|
145
|
+
const page = '<page template="default"><content location="main"><h1>Page</h1></content></page>';
|
|
146
|
+
const routeJs = 'export default async (req, res) => { res.json({source: "js"}); };';
|
|
147
|
+
await write(dir, 'public/default.template.html', template);
|
|
148
|
+
await write(dir, 'public/api/GET.js', routeJs);
|
|
149
|
+
await write(dir, 'public/api/GET.page.html', page);
|
|
150
|
+
|
|
151
|
+
const prev = process.cwd();
|
|
152
|
+
process.chdir(dir);
|
|
153
|
+
const {server, port} = await startServer(dir, {root: 'public'});
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const r = await httpGet(`http://localhost:${port}/api`);
|
|
157
|
+
if(r.res.statusCode !== 200) throw new Error(`expected 200, got ${r.res.statusCode}`);
|
|
158
|
+
const body = r.body.toString();
|
|
159
|
+
const json = JSON.parse(body);
|
|
160
|
+
if(json.source !== 'js') throw new Error(`expected js route to win, got: ${body}`);
|
|
161
|
+
} finally {
|
|
162
|
+
server.close();
|
|
163
|
+
process.chdir(prev);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
pass();
|
|
167
|
+
} catch(e) {
|
|
168
|
+
fail(e.message);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|