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({ baseDir, notFoundFilepath }) { 40 this.baseDir = baseDir; 41 this.notFoundFilepath = notFoundFilepath; 42 } 43 44 static default() { 45 return new PagesConfig({ 46 baseDir: "/", 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 baseDir: configObj.baseDir || "/", 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 fileCacheExpirationSeconds = 10, 76 }) { 77 this.ownerDid = ownerDid; 78 this.repoName = repoName; 79 this.configFilepath = configFilepath; 80 this.verbose = verbose; 81 this.client = createUnsignedClient(domain, false, verbose); 82 this.fileCache = new FileCache({ 83 expirationSeconds: fileCacheExpirationSeconds, 84 }); 85 } 86 87 async getConfig() { 88 if (!this.__config) { 89 await this.loadConfig(); 90 } 91 return this.__config; 92 } 93 94 async loadConfig() { 95 let config = null; 96 const configFileContent = await this.getFileContent(this.configFilepath); 97 if (!configFileContent) { 98 console.warn( 99 `No config file found at ${this.configFilepath}, using default config` 100 ); 101 config = PagesConfig.default(); 102 } else { 103 config = PagesConfig.fromFile(this.configFilepath, configFileContent); 104 } 105 this.__config = config; 106 return config; 107 } 108 109 async getFileContent(filename) { 110 const cachedContent = this.fileCache.get(filename); 111 if (cachedContent) { 112 if (this.verbose) { 113 console.log(`Cache hit for ${filename}`); 114 } 115 return cachedContent; 116 } 117 // todo error handling? 118 const blob = await this.client.blob( 119 this.ownerDid, 120 this.repoName, 121 "main", 122 trimLeadingSlash(filename) 123 ); 124 const content = blob.contents; 125 this.fileCache.set(filename, content); 126 return content; 127 } 128 129 async getPage(route) { 130 const config = await this.getConfig(); 131 let filePath = route; 132 const extension = path.extname(filePath); 133 if (extension === "") { 134 filePath = path.join(filePath, "index.html"); 135 } 136 137 const fullPath = path.join(config.baseDir, trimLeadingSlash(filePath)); 138 139 const content = await this.getFileContent(fullPath); 140 if (!content) { 141 return this.get404(); 142 } 143 return { 144 status: 200, 145 content, 146 contentType: getContentTypeForFilename(fullPath), 147 }; 148 } 149 150 async get404() { 151 const { notFoundFilepath } = await this.getConfig(); 152 if (notFoundFilepath) { 153 const content = await this.getFileContent(notFoundFilepath); 154 return { 155 status: 404, 156 content, 157 contentType: getContentTypeForFilename(notFoundFilepath), 158 }; 159 } 160 return { status: 404, content: "Not Found", contentType: "text/plain" }; 161 } 162} 163 164export default PagesService;