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({ basePath, notFoundFilepath }) {
40 this.basePath = basePath;
41 this.notFoundFilepath = notFoundFilepath;
42 }
43
44 static default() {
45 return new PagesConfig({
46 basePath: "/",
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 basePath: configObj.basePath || "/",
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 }) {
76 this.ownerDid = ownerDid;
77 this.repoName = repoName;
78 this.configFilepath = configFilepath;
79 this.verbose = verbose;
80 this.client = createUnsignedClient(domain, false, verbose);
81 this.fileCache = new FileCache({ expirationSeconds: 60 });
82 }
83
84 async getConfig() {
85 if (!this.__config) {
86 await this.loadConfig();
87 }
88 return this.__config;
89 }
90
91 async loadConfig() {
92 let config = null;
93 const configFileContent = await this.getFileContent(this.configFilepath);
94 if (!configFileContent) {
95 console.warn(
96 `No config file found at ${this.configFilepath}, using default config`
97 );
98 config = PagesConfig.default();
99 } else {
100 config = PagesConfig.fromFile(this.configFilepath, configFileContent);
101 }
102 this.__config = config;
103 return config;
104 }
105
106 async getFileContent(filename) {
107 const cachedContent = this.fileCache.get(filename);
108 if (cachedContent) {
109 if (this.verbose) {
110 console.log(`Cache hit for ${filename}`);
111 }
112 return cachedContent;
113 }
114 // todo error handling?
115 const blob = await this.client.blob(
116 this.ownerDid,
117 this.repoName,
118 "main",
119 trimLeadingSlash(filename)
120 );
121 const content = blob.contents;
122 this.fileCache.set(filename, content);
123 return content;
124 }
125
126 async getPage(route) {
127 const config = await this.getConfig();
128 let filePath = route;
129 const extension = path.extname(filePath);
130 if (extension === "") {
131 filePath = path.join(filePath, "index.html");
132 }
133
134 const fullPath = path.join(config.basePath, trimLeadingSlash(filePath));
135
136 const content = await this.getFileContent(fullPath);
137 if (!content) {
138 return this.get404();
139 }
140 return {
141 status: 200,
142 content,
143 contentType: getContentTypeForFilename(fullPath),
144 };
145 }
146
147 async get404() {
148 const { notFoundFilepath } = await this.getConfig();
149 if (notFoundFilepath) {
150 const content = await this.getFileContent(notFoundFilepath);
151 return {
152 status: 404,
153 content,
154 contentType: getContentTypeForFilename(notFoundFilepath),
155 };
156 }
157 return { status: 404, content: "Not Found", contentType: "text/plain" };
158 }
159}
160
161export default PagesService;