Static site hosting via tangled
1import { PagesService } from "./pages-service.js"; 2import { KnotEventListener } from "./knot-event-listener.js"; 3import { listRecords } from "./atproto.js"; 4 5async function getKnotDomain(did, repoName) { 6 const repos = await listRecords({ 7 did, 8 collection: "sh.tangled.repo", 9 }); 10 const repo = repos.find((r) => r.value.name === repoName); 11 if (!repo) { 12 throw new Error(`Repo ${repoName} not found for did ${did}`); 13 } 14 return repo.value.knot; 15} 16 17async function getPagesServiceForSite(siteOptions, config) { 18 let knotDomain = siteOptions.knotDomain; 19 if (!knotDomain) { 20 console.log( 21 "Getting knot domain for", 22 siteOptions.ownerDid + "/" + siteOptions.repoName 23 ); 24 knotDomain = await getKnotDomain( 25 siteOptions.ownerDid, 26 siteOptions.repoName 27 ); 28 } 29 return new PagesService({ 30 knotDomain, 31 ownerDid: siteOptions.ownerDid, 32 repoName: siteOptions.repoName, 33 branch: siteOptions.branch, 34 baseDir: siteOptions.baseDir, 35 notFoundFilepath: siteOptions.notFoundFilepath, 36 cache: config.cache, 37 }); 38} 39 40async function getPagesServiceMap(config) { 41 if (config.site && config.sites) { 42 throw new Error("Cannot use both site and sites in config"); 43 } 44 const pagesServiceMap = {}; 45 if (config.site) { 46 pagesServiceMap[""] = await getPagesServiceForSite(config.site, config); 47 } 48 if (config.sites) { 49 for (const site of config.sites) { 50 pagesServiceMap[site.subdomain] = await getPagesServiceForSite( 51 site, 52 config 53 ); 54 } 55 } 56 return pagesServiceMap; 57} 58 59export class Handler { 60 constructor({ config, pagesServiceMap, knotEventListeners }) { 61 this.config = config; 62 this.pagesServiceMap = pagesServiceMap; 63 this.knotEventListeners = knotEventListeners; 64 65 for (const knotEventListener of this.knotEventListeners) { 66 knotEventListener.on("refUpdate", (event) => this.handleRefUpdate(event)); 67 } 68 } 69 70 static async fromConfig(config) { 71 const pagesServiceMap = await getPagesServiceMap(config); 72 const knotDomains = new Set( 73 Object.values(pagesServiceMap).map((ps) => ps.knotDomain) 74 ); 75 const knotEventListeners = []; 76 if (config.cache) { 77 for (const knotDomain of knotDomains) { 78 const eventListener = new KnotEventListener({ 79 knotDomain, 80 }); 81 knotEventListeners.push(eventListener); 82 } 83 } 84 await Promise.all( 85 knotEventListeners.map((eventListener) => eventListener.start()) 86 ); 87 return new Handler({ config, pagesServiceMap, knotEventListeners }); 88 } 89 90 handleRefUpdate(event) { 91 const { ownerDid, repoName } = event.details; 92 const pagesService = Object.values(this.pagesServiceMap).find( 93 (ps) => ps.ownerDid === ownerDid && ps.repoName === repoName 94 ); 95 if (pagesService) { 96 pagesService.clearCache(); 97 } 98 } 99 100 async handleRequest({ host, path }) { 101 // Single site mode 102 const singleSite = this.pagesServiceMap[""]; 103 if (singleSite) { 104 const { status, content, contentType } = await singleSite.getPage(path); 105 return { status, content, contentType }; 106 } 107 // Multi site mode 108 const subdomainOffset = this.config.subdomainOffset ?? 2; 109 const subdomain = host.split(".").at((subdomainOffset + 1) * -1); 110 if (!subdomain) { 111 return { 112 status: 200, 113 content: "Tangled pages is running! Sites can be found at subdomains.", 114 contentType: "text/plain", 115 }; 116 } 117 const matchingSite = this.pagesServiceMap[subdomain]; 118 if (matchingSite) { 119 const { status, content, contentType } = await matchingSite.getPage(path); 120 return { status, content, contentType }; 121 } 122 console.log("No matching site found for subdomain", subdomain); 123 return { status: 404, content: "Not Found", contentType: "text/plain" }; 124 } 125}