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 const blob = await res.json();
127 const content = blob.contents;
128 this.fileCache.set(filename, content);
129 return content;
130 }
131
132 async getPage(route) {
133 const config = await this.getConfig();
134 let filePath = route;
135 const extension = path.extname(filePath);
136 if (extension === "") {
137 filePath = path.join(filePath, "index.html");
138 }
139
140 const fullPath = path.join(config.baseDir, trimLeadingSlash(filePath));
141
142 const content = await this.getFileContent(fullPath);
143 if (!content) {
144 return this.get404();
145 }
146 return {
147 status: 200,
148 content,
149 contentType: getContentTypeForFilename(fullPath),
150 };
151 }
152
153 async get404() {
154 const { notFoundFilepath } = await this.getConfig();
155 if (notFoundFilepath) {
156 const content = await this.getFileContent(notFoundFilepath);
157 return {
158 status: 404,
159 content,
160 contentType: getContentTypeForFilename(notFoundFilepath),
161 };
162 }
163 return { status: 404, content: "Not Found", contentType: "text/plain" };
164 }
165}
166
167export default PagesService;