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 PagesService {
68 constructor({
69 domain,
70 ownerDid,
71 repoName,
72 branch,
73 configFilepath = "pages_config.yaml",
74 verbose = false,
75 fileCacheExpirationSeconds = 10,
76 }) {
77 this.domain = domain;
78 this.ownerDid = ownerDid;
79 this.repoName = repoName;
80 this.branch = branch;
81 this.configFilepath = configFilepath;
82 this.verbose = verbose;
83 this.fileCache = new FileCache({
84 expirationSeconds: fileCacheExpirationSeconds,
85 });
86 }
87
88 async getConfig() {
89 if (!this.__config) {
90 await this.loadConfig();
91 }
92 return this.__config;
93 }
94
95 async loadConfig() {
96 let config = null;
97 const configFileContent = await this.getFileContent(this.configFilepath);
98 if (!configFileContent) {
99 console.warn(
100 `No config file found at ${this.configFilepath}, using default config`
101 );
102 config = PagesConfig.default();
103 } else {
104 config = PagesConfig.fromFile(this.configFilepath, configFileContent);
105 }
106 this.__config = config;
107 return config;
108 }
109
110 async getFileContent(filename) {
111 const cachedContent = this.fileCache.get(filename);
112 if (cachedContent) {
113 if (this.verbose) {
114 console.log(`Cache hit for ${filename}`);
115 }
116 return cachedContent;
117 }
118 // todo error handling?
119 const url = `https://${this.domain}/${this.ownerDid}/${
120 this.repoName
121 }/blob/${this.branch}/${trimLeadingSlash(filename)}`;
122 if (this.verbose) {
123 console.log(`Fetching ${url}`);
124 }
125 const res = await fetch(url, {
126 headers: {
127 Accept: "application/json",
128 },
129 });
130 const blob = await res.json();
131 const content = blob.contents;
132 this.fileCache.set(filename, content);
133 return content;
134 }
135
136 async getPage(route) {
137 const config = await this.getConfig();
138 let filePath = route;
139 const extension = path.extname(filePath);
140 if (extension === "") {
141 filePath = path.join(filePath, "index.html");
142 }
143
144 const fullPath = path.join(config.baseDir, trimLeadingSlash(filePath));
145
146 const content = await this.getFileContent(fullPath);
147 if (!content) {
148 return this.get404();
149 }
150 return {
151 status: 200,
152 content,
153 contentType: getContentTypeForFilename(fullPath),
154 };
155 }
156
157 async get404() {
158 const { notFoundFilepath } = await this.getConfig();
159 if (notFoundFilepath) {
160 const content = await this.getFileContent(notFoundFilepath);
161 return {
162 status: 404,
163 content,
164 contentType: getContentTypeForFilename(notFoundFilepath),
165 };
166 }
167 return { status: 404, content: "Not Found", contentType: "text/plain" };
168 }
169}
170
171export default PagesService;