Static site hosting via tangled
1import { getContentTypeForExtension, trimLeadingSlash } from "./helpers.js";
2import path from "node:path";
3import yaml from "yaml";
4
5// Helpers
6
7function getContentTypeForFilename(filename) {
8 const extension = path.extname(filename).toLowerCase();
9 return getContentTypeForExtension(extension);
10}
11
12class FileCache {
13 constructor({ expirationSeconds = 60 }) {
14 this.cache = new Map();
15 this.expirationSeconds = expirationSeconds;
16 }
17
18 get(filename) {
19 if (this.cache.has(filename)) {
20 const entry = this.cache.get(filename);
21 if (
22 entry &&
23 entry.timestamp > Date.now() - this.expirationSeconds * 1000
24 ) {
25 return entry.content;
26 }
27 }
28 return null;
29 }
30
31 set(filename, content) {
32 const timestamp = Date.now();
33 this.cache.set(filename, { content, timestamp });
34 }
35}
36
37class PagesConfig {
38 constructor({ baseDir, notFoundFilepath }) {
39 this.baseDir = baseDir;
40 this.notFoundFilepath = notFoundFilepath;
41 }
42
43 static default() {
44 return new PagesConfig({
45 baseDir: "/",
46 notFoundFilepath: null,
47 });
48 }
49
50 static fromFile(filePath, fileContent) {
51 if (!filePath.endsWith(".yaml")) {
52 throw new Error("Config file must be a YAML file");
53 }
54 let configObj = {};
55 try {
56 configObj = yaml.parse(fileContent);
57 } catch (error) {
58 throw new Error(`Error parsing YAML file ${filePath}: ${error}`);
59 }
60 return new PagesConfig({
61 baseDir: configObj.baseDir || "/",
62 notFoundFilepath: configObj.notFoundFilepath || null,
63 });
64 }
65}
66
67class KnotClient {
68 constructor({ domain, ownerDid, repoName, branch }) {
69 this.domain = domain;
70 this.ownerDid = ownerDid;
71 this.repoName = repoName;
72 this.branch = branch;
73 }
74
75 async getBlob(filename) {
76 const url = `https://${this.domain}/${this.ownerDid}/${
77 this.repoName
78 }/blob/${this.branch}/${trimLeadingSlash(filename)}`;
79 const res = await fetch(url);
80 return await res.json();
81 }
82
83 async getRaw(filename) {
84 const url = `https://${this.domain}/${this.ownerDid}/${this.repoName}/raw/${
85 this.branch
86 }/${trimLeadingSlash(filename)}`;
87 const res = await fetch(url, {
88 responseType: "arraybuffer",
89 });
90 const arrayBuffer = await res.arrayBuffer();
91 return Buffer.from(arrayBuffer);
92 }
93}
94
95class PagesService {
96 constructor({
97 domain,
98 ownerDid,
99 repoName,
100 branch,
101 configFilepath = "pages_config.yaml",
102 fileCacheExpirationSeconds = 10,
103 }) {
104 this.domain = domain;
105 this.ownerDid = ownerDid;
106 this.repoName = repoName;
107 this.branch = branch;
108 this.configFilepath = configFilepath;
109 this.fileCache = new FileCache({
110 expirationSeconds: fileCacheExpirationSeconds,
111 });
112 this.client = new KnotClient({
113 domain: domain,
114 ownerDid: ownerDid,
115 repoName: repoName,
116 branch: branch,
117 });
118 }
119
120 async getConfig() {
121 if (!this.__config) {
122 await this.loadConfig();
123 }
124 return this.__config;
125 }
126
127 async loadConfig() {
128 let config = null;
129 const configFileContent = await this.getFileContent(this.configFilepath);
130 if (!configFileContent) {
131 console.warn(
132 `No config file found at ${this.configFilepath}, using default config`
133 );
134 config = PagesConfig.default();
135 } else {
136 config = PagesConfig.fromFile(this.configFilepath, configFileContent);
137 }
138 this.__config = config;
139 return config;
140 }
141
142 async getFileContent(filename) {
143 const cachedContent = this.fileCache.get(filename);
144 if (cachedContent) {
145 return cachedContent;
146 }
147 let content = null;
148 const blob = await this.client.getBlob(filename);
149 if (blob.is_binary) {
150 content = await this.client.getRaw(filename);
151 } else {
152 content = blob.contents;
153 }
154 this.fileCache.set(filename, content);
155 return content;
156 }
157
158 async getPage(route) {
159 const config = await this.getConfig();
160 let filePath = route;
161 const extension = path.extname(filePath);
162 if (extension === "") {
163 filePath = path.join(filePath, "index.html");
164 }
165
166 const fullPath = path.join(config.baseDir, trimLeadingSlash(filePath));
167
168 const content = await this.getFileContent(fullPath);
169 if (!content) {
170 return this.get404();
171 }
172 return {
173 status: 200,
174 content,
175 contentType: getContentTypeForFilename(fullPath),
176 };
177 }
178
179 async get404() {
180 const { notFoundFilepath } = await this.getConfig();
181 if (notFoundFilepath) {
182 const content = await this.getFileContent(notFoundFilepath);
183 return {
184 status: 404,
185 content,
186 contentType: getContentTypeForFilename(notFoundFilepath),
187 };
188 }
189 return { status: 404, content: "Not Found", contentType: "text/plain" };
190 }
191}
192
193export default PagesService;