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 await eventListener.start();
82 knotEventListeners.push(eventListener);
83 }
84 }
85 return new Handler({ config, pagesServiceMap, knotEventListeners });
86 }
87
88 handleRefUpdate(event) {
89 const { ownerDid, repoName } = event.details;
90 const pagesService = Object.values(this.pagesServiceMap).find(
91 (ps) => ps.ownerDid === ownerDid && ps.repoName === repoName
92 );
93 if (pagesService) {
94 pagesService.clearCache();
95 }
96 }
97
98 async handleRequest({ host, path }) {
99 // Single site mode
100 const singleSite = this.pagesServiceMap[""];
101 if (singleSite) {
102 const { status, content, contentType } = await singleSite.getPage(path);
103 return { status, content, contentType };
104 }
105 // Multi site mode
106 const subdomainOffset = this.config.subdomainOffset ?? 2;
107 const subdomain = host.split(".").at((subdomainOffset + 1) * -1);
108 if (!subdomain) {
109 return {
110 status: 200,
111 content: "Tangled pages is running! Sites can be found at subdomains.",
112 contentType: "text/plain",
113 };
114 }
115 const matchingSite = this.pagesServiceMap[subdomain];
116 if (matchingSite) {
117 const { status, content, contentType } = await matchingSite.getPage(path);
118 return { status, content, contentType };
119 }
120 console.log("No matching site found for subdomain", subdomain);
121 return { status: 404, content: "Not Found", contentType: "text/plain" };
122 }
123}