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