Static site hosting via tangled
1import {
2 getContentTypeForFilename,
3 trimLeadingSlash,
4 extname,
5 joinurl,
6} from "./helpers.js";
7import { KnotClient } from "./knot-client.js";
8
9class FileCache {
10 constructor({ maxSize } = {}) {
11 this.cache = new Map();
12 this.maxSize = maxSize;
13 }
14
15 get(filename) {
16 return this.cache.get(filename) ?? null;
17 }
18
19 set(filename, content) {
20 this.cache.set(filename, content);
21 // Evict oldest item if cache is full
22 if (this.maxSize && this.cache.size > this.maxSize) {
23 const oldestKey = this.cache.keys().next().value;
24 this.cache.delete(oldestKey);
25 }
26 }
27
28 clear() {
29 this.cache.clear();
30 }
31}
32
33export class PagesService {
34 constructor({
35 knotDomain,
36 ownerDid,
37 repoName,
38 branch = "main",
39 baseDir = "/",
40 notFoundFilepath = null,
41 cache,
42 }) {
43 this.knotDomain = knotDomain;
44 this.ownerDid = ownerDid;
45 this.repoName = repoName;
46 this.branch = branch;
47 this.baseDir = baseDir;
48 this.notFoundFilepath = notFoundFilepath;
49 this.client = new KnotClient({
50 domain: knotDomain,
51 ownerDid,
52 repoName,
53 branch,
54 });
55 this.fileCache = null;
56 if (cache) {
57 console.log("Enabling cache for", this.ownerDid, this.repoName);
58 this.fileCache = new FileCache({ maxSize: 100 });
59 }
60 }
61
62 async getFileContent(filename) {
63 const cachedContent = this.fileCache?.get(filename);
64 if (cachedContent) {
65 console.log("Cache hit for", filename);
66 return cachedContent;
67 }
68 let content = null;
69 const blob = await this.client.getBlob(filename);
70 if (blob.isBinary) {
71 content = await this.client.getRaw(filename);
72 } else {
73 content = blob.content;
74 }
75 if (this.fileCache && content) {
76 const contentSize = Buffer.isBuffer(content)
77 ? content.length
78 : Buffer.byteLength(content, "utf8");
79 // Cache unless content is too large (5MB)
80 if (contentSize < 5 * 1024 * 1024) {
81 this.fileCache.set(filename, content);
82 }
83 }
84 return content;
85 }
86
87 async getPage(route) {
88 let filePath = route;
89 const extension = extname(filePath);
90 if (!extension) {
91 filePath = joinurl(filePath, "index.html");
92 }
93 const fullPath = joinurl(this.baseDir, trimLeadingSlash(filePath));
94 const content = await this.getFileContent(fullPath);
95 if (!content) {
96 return this.get404();
97 }
98 return {
99 status: 200,
100 content,
101 contentType: getContentTypeForFilename(fullPath),
102 };
103 }
104
105 async get404() {
106 if (this.notFoundFilepath) {
107 const fullPath = joinurl(
108 this.baseDir,
109 trimLeadingSlash(this.notFoundFilepath)
110 );
111 const content = await this.getFileContent(fullPath);
112 if (!content) {
113 console.warn("'Not found' file not found", fullPath);
114 return { status: 404, content: "Not Found", contentType: "text/plain" };
115 }
116 return {
117 status: 404,
118 content,
119 contentType: getContentTypeForFilename(this.notFoundFilepath),
120 };
121 }
122 return { status: 404, content: "Not Found", contentType: "text/plain" };
123 }
124
125 async clearCache() {
126 if (!this.fileCache) {
127 console.log("No cache to clear for", this.ownerDid, this.repoName);
128 return;
129 }
130 console.log("Clearing cache for", this.ownerDid, this.repoName);
131 this.fileCache.clear();
132 }
133}