import { PagesService } from "./pages-service.js"; import { KnotEventListener } from "./knot-event-listener.js"; import { listRecords, resolveHandle } from "./atproto.js"; async function getKnotDomain(did, repoName) { const repos = await listRecords({ did, collection: "sh.tangled.repo", }); const repo = repos.find((r) => r.value.name === repoName); if (!repo) { throw new Error(`Repo ${repoName} not found for did ${did}`); } return repo.value.knot; } function parseTangledUrl(tangledUrl) { // e.g. https://tangled.sh/@gracekind.net/tangled-pages-example const regex = /^https:\/\/tangled\.sh\/@(.+)\/(.+)$/; const match = tangledUrl.match(regex); if (!match) { throw new Error(`Invalid tangled URL: ${tangledUrl}`); } return { handle: match[1], repoName: match[2], }; } async function getPagesServiceForSite(siteOptions, config) { // Fetch repoName and ownerDid if needed let ownerDid = siteOptions.ownerDid; let repoName = siteOptions.repoName; if (siteOptions.tangledUrl) { const { handle, repoName: parsedRepoName } = parseTangledUrl( siteOptions.tangledUrl ); console.log("Getting ownerDid for", handle); const did = await resolveHandle(handle); ownerDid = did; repoName = parsedRepoName; } // Fetch knot domain if needed let knotDomain = siteOptions.knotDomain; if (!knotDomain) { console.log("Getting knot domain for", ownerDid + "/" + repoName); knotDomain = await getKnotDomain(ownerDid, repoName); } return new PagesService({ knotDomain, ownerDid, repoName, branch: siteOptions.branch, baseDir: siteOptions.baseDir, notFoundFilepath: siteOptions.notFoundFilepath, cache: config.cache, }); } async function getPagesServiceMap(config) { const pagesServiceMap = {}; if (config.site) { pagesServiceMap[""] = await getPagesServiceForSite(config.site, config); } if (config.sites) { for (const site of config.sites) { pagesServiceMap[site.subdomain] = await getPagesServiceForSite( site, config ); } } return pagesServiceMap; } export class Handler { constructor({ config, pagesServiceMap, knotEventListeners }) { this.config = config; this.pagesServiceMap = pagesServiceMap; this.knotEventListeners = knotEventListeners; for (const knotEventListener of this.knotEventListeners) { knotEventListener.on("refUpdate", (event) => this.handleRefUpdate(event)); } } static async fromConfig(config) { const pagesServiceMap = await getPagesServiceMap(config); const knotDomains = new Set( Object.values(pagesServiceMap).map((ps) => ps.knotDomain) ); const knotEventListeners = []; if (config.cache) { for (const knotDomain of knotDomains) { const eventListener = new KnotEventListener({ knotDomain, }); knotEventListeners.push(eventListener); } } await Promise.all( knotEventListeners.map((eventListener) => eventListener.start()) ); return new Handler({ config, pagesServiceMap, knotEventListeners }); } handleRefUpdate(event) { const { ownerDid, repoName } = event.details; const pagesService = Object.values(this.pagesServiceMap).find( (ps) => ps.ownerDid === ownerDid && ps.repoName === repoName ); if (pagesService) { pagesService.clearCache(); } } async handleRequest({ host, path }) { // Single site mode const singleSite = this.pagesServiceMap[""]; if (singleSite) { const { status, content, contentType } = await singleSite.getPage(path); return { status, content, contentType }; } // Multi site mode const subdomainOffset = this.config.subdomainOffset ?? 2; const subdomain = host.split(".").at((subdomainOffset + 1) * -1); if (!subdomain) { return { status: 200, content: "Tangled pages is running! Sites can be found at subdomains.", contentType: "text/plain", }; } const matchingSite = this.pagesServiceMap[subdomain]; if (matchingSite) { const { status, content, contentType } = await matchingSite.getPage(path); return { status, content, contentType }; } console.log("No matching site found for subdomain", subdomain); return { status: 404, content: "Not Found", contentType: "text/plain" }; } }