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}