Static site hosting via tangled
at main 4.4 kB view raw
1import { PagesService } from "./pages-service.js"; 2import { KnotEventListener } from "./knot-event-listener.js"; 3import { listRecords, resolveHandle } 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 17function parseTangledUrl(tangledUrl) { 18 // e.g. https://tangled.sh/@gracekind.net/tangled-pages-example 19 const regex = /^https:\/\/tangled\.sh\/@(.+)\/(.+)$/; 20 const match = tangledUrl.match(regex); 21 if (!match) { 22 throw new Error(`Invalid tangled URL: ${tangledUrl}`); 23 } 24 return { 25 handle: match[1], 26 repoName: match[2], 27 }; 28} 29 30async function getPagesServiceForSite(siteOptions, config) { 31 // Fetch repoName and ownerDid if needed 32 let ownerDid = siteOptions.ownerDid; 33 let repoName = siteOptions.repoName; 34 35 if (siteOptions.tangledUrl) { 36 const { handle, repoName: parsedRepoName } = parseTangledUrl( 37 siteOptions.tangledUrl 38 ); 39 console.log("Getting ownerDid for", handle); 40 const did = await resolveHandle(handle); 41 ownerDid = did; 42 repoName = parsedRepoName; 43 } 44 // Fetch knot domain if needed 45 let knotDomain = siteOptions.knotDomain; 46 if (!knotDomain) { 47 console.log("Getting knot domain for", ownerDid + "/" + repoName); 48 knotDomain = await getKnotDomain(ownerDid, repoName); 49 } 50 return new PagesService({ 51 knotDomain, 52 ownerDid, 53 repoName, 54 branch: siteOptions.branch, 55 baseDir: siteOptions.baseDir, 56 notFoundFilepath: siteOptions.notFoundFilepath, 57 cache: config.cache, 58 }); 59} 60 61async function getPagesServiceMap(config) { 62 const pagesServiceMap = {}; 63 if (config.site) { 64 pagesServiceMap[""] = await getPagesServiceForSite(config.site, config); 65 } 66 if (config.sites) { 67 for (const site of config.sites) { 68 pagesServiceMap[site.subdomain] = await getPagesServiceForSite( 69 site, 70 config 71 ); 72 } 73 } 74 return pagesServiceMap; 75} 76 77export class Handler { 78 constructor({ config, pagesServiceMap, knotEventListeners }) { 79 this.config = config; 80 this.pagesServiceMap = pagesServiceMap; 81 this.knotEventListeners = knotEventListeners; 82 83 for (const knotEventListener of this.knotEventListeners) { 84 knotEventListener.on("refUpdate", (event) => this.handleRefUpdate(event)); 85 } 86 } 87 88 static async fromConfig(config) { 89 const pagesServiceMap = await getPagesServiceMap(config); 90 const knotDomains = new Set( 91 Object.values(pagesServiceMap).map((ps) => ps.knotDomain) 92 ); 93 const knotEventListeners = []; 94 if (config.cache) { 95 for (const knotDomain of knotDomains) { 96 const eventListener = new KnotEventListener({ 97 knotDomain, 98 }); 99 knotEventListeners.push(eventListener); 100 } 101 } 102 await Promise.all( 103 knotEventListeners.map((eventListener) => eventListener.start()) 104 ); 105 return new Handler({ config, pagesServiceMap, knotEventListeners }); 106 } 107 108 handleRefUpdate(event) { 109 const { ownerDid, repoName } = event.details; 110 const pagesService = Object.values(this.pagesServiceMap).find( 111 (ps) => ps.ownerDid === ownerDid && ps.repoName === repoName 112 ); 113 if (pagesService) { 114 pagesService.clearCache(); 115 } 116 } 117 118 async handleRequest({ host, path }) { 119 // Single site mode 120 const singleSite = this.pagesServiceMap[""]; 121 if (singleSite) { 122 const { status, content, contentType } = await singleSite.getPage(path); 123 return { status, content, contentType }; 124 } 125 // Multi site mode 126 const subdomainOffset = this.config.subdomainOffset ?? 2; 127 const subdomain = host.split(".").at((subdomainOffset + 1) * -1); 128 if (!subdomain) { 129 return { 130 status: 200, 131 content: "Tangled pages is running! Sites can be found at subdomains.", 132 contentType: "text/plain", 133 }; 134 } 135 const matchingSite = this.pagesServiceMap[subdomain]; 136 if (matchingSite) { 137 const { status, content, contentType } = await matchingSite.getPage(path); 138 return { status, content, contentType }; 139 } 140 console.log("No matching site found for subdomain", subdomain); 141 return { status: 404, content: "Not Found", contentType: "text/plain" }; 142 } 143}