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