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 KnotClient { 68 constructor({ domain, ownerDid, repoName, branch }) { 69 this.domain = domain; 70 this.ownerDid = ownerDid; 71 this.repoName = repoName; 72 this.branch = branch; 73 } 74 75 async getBlob(filename) { 76 const url = `https://${this.domain}/${this.ownerDid}/${ 77 this.repoName 78 }/blob/${this.branch}/${trimLeadingSlash(filename)}`; 79 const res = await fetch(url); 80 return await res.json(); 81 } 82 83 async getRaw(filename) { 84 const url = `https://${this.domain}/${this.ownerDid}/${this.repoName}/raw/${ 85 this.branch 86 }/${trimLeadingSlash(filename)}`; 87 const res = await fetch(url, { 88 responseType: "arraybuffer", 89 }); 90 const arrayBuffer = await res.arrayBuffer(); 91 return Buffer.from(arrayBuffer); 92 } 93} 94 95class PagesService { 96 constructor({ 97 domain, 98 ownerDid, 99 repoName, 100 branch, 101 configFilepath = "pages_config.yaml", 102 fileCacheExpirationSeconds = 10, 103 }) { 104 this.domain = domain; 105 this.ownerDid = ownerDid; 106 this.repoName = repoName; 107 this.branch = branch; 108 this.configFilepath = configFilepath; 109 this.fileCache = new FileCache({ 110 expirationSeconds: fileCacheExpirationSeconds, 111 }); 112 this.client = new KnotClient({ 113 domain: domain, 114 ownerDid: ownerDid, 115 repoName: repoName, 116 branch: branch, 117 }); 118 } 119 120 async getConfig() { 121 if (!this.__config) { 122 await this.loadConfig(); 123 } 124 return this.__config; 125 } 126 127 async loadConfig() { 128 let config = null; 129 const configFileContent = await this.getFileContent(this.configFilepath); 130 if (!configFileContent) { 131 console.warn( 132 `No config file found at ${this.configFilepath}, using default config` 133 ); 134 config = PagesConfig.default(); 135 } else { 136 config = PagesConfig.fromFile(this.configFilepath, configFileContent); 137 } 138 this.__config = config; 139 return config; 140 } 141 142 async getFileContent(filename) { 143 const cachedContent = this.fileCache.get(filename); 144 if (cachedContent) { 145 return cachedContent; 146 } 147 let content = null; 148 const blob = await this.client.getBlob(filename); 149 if (blob.is_binary) { 150 content = await this.client.getRaw(filename); 151 } else { 152 content = blob.contents; 153 } 154 this.fileCache.set(filename, content); 155 return content; 156 } 157 158 async getPage(route) { 159 const config = await this.getConfig(); 160 let filePath = route; 161 const extension = path.extname(filePath); 162 if (extension === "") { 163 filePath = path.join(filePath, "index.html"); 164 } 165 166 const fullPath = path.join(config.baseDir, trimLeadingSlash(filePath)); 167 168 const content = await this.getFileContent(fullPath); 169 if (!content) { 170 return this.get404(); 171 } 172 return { 173 status: 200, 174 content, 175 contentType: getContentTypeForFilename(fullPath), 176 }; 177 } 178 179 async get404() { 180 const { notFoundFilepath } = await this.getConfig(); 181 if (notFoundFilepath) { 182 const content = await this.getFileContent(notFoundFilepath); 183 return { 184 status: 404, 185 content, 186 contentType: getContentTypeForFilename(notFoundFilepath), 187 }; 188 } 189 return { status: 404, content: "Not Found", contentType: "text/plain" }; 190 } 191} 192 193export default PagesService;