Static site hosting via tangled
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}