Static site hosting via tangled
1import { createUnsignedClient } from "./knot-client.js"; 2import { getContentTypeForExtension, trimLeadingSlash } from "./helpers.js"; 3import path from "node:path"; 4import yaml from "yaml"; 5 6// Helpers 7 8function getContentTypeForFilename(filename) { 9 const extension = path.extname(filename).toLowerCase(); 10 return getContentTypeForExtension(extension); 11} 12 13class FileCache { 14 constructor({ expirationSeconds = 60 }) { 15 this.cache = new Map(); 16 this.expirationSeconds = expirationSeconds; 17 } 18 19 get(filename) { 20 if (this.cache.has(filename)) { 21 const entry = this.cache.get(filename); 22 if ( 23 entry && 24 entry.timestamp > Date.now() - this.expirationSeconds * 1000 25 ) { 26 return entry.content; 27 } 28 } 29 return null; 30 } 31 32 set(filename, content) { 33 const timestamp = Date.now(); 34 this.cache.set(filename, { content, timestamp }); 35 } 36} 37 38class PagesConfig { 39 constructor({ basePath, notFoundFilepath }) { 40 this.basePath = basePath; 41 this.notFoundFilepath = notFoundFilepath; 42 } 43 44 static default() { 45 return new PagesConfig({ 46 basePath: "/", 47 notFoundFilepath: null, 48 }); 49 } 50 51 static fromFile(filePath, fileContent) { 52 if (!filePath.endsWith(".yaml")) { 53 throw new Error("Config file must be a YAML file"); 54 } 55 let configObj = {}; 56 try { 57 configObj = yaml.parse(fileContent); 58 } catch (error) { 59 throw new Error(`Error parsing YAML file ${filePath}: ${error}`); 60 } 61 return new PagesConfig({ 62 basePath: configObj.basePath || "/", 63 notFoundFilepath: configObj.notFoundFilepath || null, 64 }); 65 } 66} 67 68class PagesService { 69 constructor({ 70 domain, 71 ownerDid, 72 repoName, 73 configFilepath = "pages_config.yaml", 74 verbose = false, 75 }) { 76 this.ownerDid = ownerDid; 77 this.repoName = repoName; 78 this.configFilepath = configFilepath; 79 this.verbose = verbose; 80 this.client = createUnsignedClient(domain, false, verbose); 81 this.fileCache = new FileCache({ expirationSeconds: 60 }); 82 } 83 84 async getConfig() { 85 if (!this.__config) { 86 await this.loadConfig(); 87 } 88 return this.__config; 89 } 90 91 async loadConfig() { 92 let config = null; 93 const configFileContent = await this.getFileContent(this.configFilepath); 94 if (!configFileContent) { 95 console.warn( 96 `No config file found at ${this.configFilepath}, using default config` 97 ); 98 config = PagesConfig.default(); 99 } else { 100 config = PagesConfig.fromFile(this.configFilepath, configFileContent); 101 } 102 this.__config = config; 103 return config; 104 } 105 106 async getFileContent(filename) { 107 const cachedContent = this.fileCache.get(filename); 108 if (cachedContent) { 109 if (this.verbose) { 110 console.log(`Cache hit for ${filename}`); 111 } 112 return cachedContent; 113 } 114 // todo error handling? 115 const blob = await this.client.blob( 116 this.ownerDid, 117 this.repoName, 118 "main", 119 trimLeadingSlash(filename) 120 ); 121 const content = blob.contents; 122 this.fileCache.set(filename, content); 123 return content; 124 } 125 126 async getPage(route) { 127 const config = await this.getConfig(); 128 let filePath = route; 129 const extension = path.extname(filePath); 130 if (extension === "") { 131 filePath = path.join(filePath, "index.html"); 132 } 133 134 const fullPath = path.join(config.basePath, trimLeadingSlash(filePath)); 135 136 const content = await this.getFileContent(fullPath); 137 if (!content) { 138 return this.get404(); 139 } 140 return { 141 status: 200, 142 content, 143 contentType: getContentTypeForFilename(fullPath), 144 }; 145 } 146 147 async get404() { 148 const { notFoundFilepath } = await this.getConfig(); 149 if (notFoundFilepath) { 150 const content = await this.getFileContent(notFoundFilepath); 151 return { 152 status: 404, 153 content, 154 contentType: getContentTypeForFilename(notFoundFilepath), 155 }; 156 } 157 return { status: 404, content: "Not Found", contentType: "text/plain" }; 158 } 159} 160 161export default PagesService;