Static site hosting via tangled

Caching for server

+3 -3
README.md
··· 16 16 "branch": "main", // optional, defaults to main 17 17 "baseDir": "/public", // optional, defaults to the repo root 18 18 "notFoundFilepath": "/404.html" // optional, defaults to text 404 19 - } 19 + }, 20 + "cache": true // server only, not supported in workers (yet) 20 21 } 21 22 ``` 22 23 ··· 35 36 36 37 ## Limitations 37 38 38 - The server fetches files from the repo on request, so it might be slow. 39 - In the future, we could cache the files and use a CI to clear the cache as needed. 39 + When `cache: false`, the server fetches files from the repo on every request, so it might be slow.
+2 -1
config.example.json
··· 5 5 "branch": "main", 6 6 "baseDir": "/public", 7 7 "notFoundFilepath": "/404.html" 8 - } 8 + }, 9 + "cache": true 9 10 }
+2 -1
src/config.js
··· 1 1 export class Config { 2 - constructor({ site, sites, subdomainOffset }) { 2 + constructor({ site, sites, subdomainOffset, cache = false }) { 3 3 this.site = site; 4 4 this.sites = sites; 5 5 this.subdomainOffset = subdomainOffset; 6 + this.cache = cache; 6 7 } 7 8 8 9 static async fromFile(filepath) {
+38 -5
src/handler.js
··· 1 1 import { PagesService } from "./pages-service.js"; 2 + import { KnotEventListener } from "./knot-event-listener.js"; 2 3 import { listRecords } from "./atproto.js"; 3 4 4 5 async function getKnotDomain(did, repoName) { ··· 13 14 return repo.value.knot; 14 15 } 15 16 16 - async function getPagesServiceForSite(siteOptions) { 17 + async function getPagesServiceForSite(siteOptions, config) { 17 18 let knotDomain = siteOptions.knotDomain; 18 19 if (!knotDomain) { 19 20 console.log( ··· 32 33 branch: siteOptions.branch, 33 34 baseDir: siteOptions.baseDir, 34 35 notFoundFilepath: siteOptions.notFoundFilepath, 36 + cache: config.cache, 35 37 }); 36 38 } 37 39 ··· 41 43 } 42 44 const pagesServiceMap = {}; 43 45 if (config.site) { 44 - pagesServiceMap[""] = await getPagesServiceForSite(config.site); 46 + pagesServiceMap[""] = await getPagesServiceForSite(config.site, config); 45 47 } 46 48 if (config.sites) { 47 49 for (const site of config.sites) { 48 - pagesServiceMap[site.subdomain] = await getPagesServiceForSite(site); 50 + pagesServiceMap[site.subdomain] = await getPagesServiceForSite( 51 + site, 52 + config 53 + ); 49 54 } 50 55 } 51 56 return pagesServiceMap; 52 57 } 53 58 54 59 export class Handler { 55 - constructor({ config, pagesServiceMap }) { 60 + constructor({ config, pagesServiceMap, knotEventListeners }) { 56 61 this.config = config; 57 62 this.pagesServiceMap = pagesServiceMap; 63 + this.knotEventListeners = knotEventListeners; 64 + 65 + for (const knotEventListener of this.knotEventListeners) { 66 + knotEventListener.on("refUpdate", (event) => this.handleRefUpdate(event)); 67 + } 58 68 } 59 69 60 70 static async fromConfig(config) { 61 71 const pagesServiceMap = await getPagesServiceMap(config); 62 - return new Handler({ config, pagesServiceMap }); 72 + const knotDomains = new Set( 73 + Object.values(pagesServiceMap).map((ps) => ps.knotDomain) 74 + ); 75 + const knotEventListeners = []; 76 + if (config.cache) { 77 + for (const knotDomain of knotDomains) { 78 + const eventListener = new KnotEventListener({ 79 + knotDomain, 80 + }); 81 + await eventListener.start(); 82 + knotEventListeners.push(eventListener); 83 + } 84 + } 85 + return new Handler({ config, pagesServiceMap, knotEventListeners }); 86 + } 87 + 88 + handleRefUpdate(event) { 89 + const { ownerDid, repoName } = event.details; 90 + const pagesService = Object.values(this.pagesServiceMap).find( 91 + (ps) => ps.ownerDid === ownerDid && ps.repoName === repoName 92 + ); 93 + if (pagesService) { 94 + pagesService.clearCache(); 95 + } 63 96 } 64 97 65 98 async handleRequest({ host, path }) {
+31
src/knot-event-listener.js
··· 1 + import EventEmitter from "node:events"; 2 + 3 + export class KnotEventListener extends EventEmitter { 4 + constructor({ knotDomain }) { 5 + super(); 6 + this.knotDomain = knotDomain; 7 + } 8 + 9 + async start() { 10 + const ws = new WebSocket(`wss://${this.knotDomain}/events`); 11 + ws.onmessage = (event) => this.handleMessage(event); 12 + return new Promise((resolve) => { 13 + ws.onopen = () => { 14 + resolve(); 15 + }; 16 + }); 17 + } 18 + 19 + async handleMessage(event) { 20 + const data = JSON.parse(event.data); 21 + if (data.nsid === "sh.tangled.git.refUpdate") { 22 + const event = { 23 + details: { 24 + ownerDid: data.event.repoDid, 25 + repoName: data.event.repoName, 26 + }, 27 + }; 28 + this.emit("refUpdate", event); 29 + } 30 + } 31 + }
+45 -2
src/pages-service.js
··· 6 6 } from "./helpers.js"; 7 7 import { KnotClient } from "./knot-client.js"; 8 8 9 + class FileCache { 10 + constructor() { 11 + this.cache = new Map(); 12 + } 13 + 14 + get(filename) { 15 + return this.cache.get(filename) ?? null; 16 + } 17 + 18 + set(filename, content) { 19 + this.cache.set(filename, content); 20 + } 21 + 22 + clear() { 23 + this.cache.clear(); 24 + } 25 + } 26 + 9 27 export class PagesService { 10 28 constructor({ 11 29 knotDomain, ··· 14 32 branch = "main", 15 33 baseDir = "/", 16 34 notFoundFilepath = null, 35 + cache, 17 36 }) { 18 37 this.knotDomain = knotDomain; 19 38 this.ownerDid = ownerDid; ··· 27 46 repoName, 28 47 branch, 29 48 }); 49 + this.fileCache = null; 50 + if (cache) { 51 + console.log("Enabling cache for", this.ownerDid, this.repoName); 52 + this.fileCache = new FileCache(); 53 + } 30 54 } 31 55 32 56 async getFileContent(filename) { 57 + const cachedContent = this.fileCache?.get(filename); 58 + if (cachedContent) { 59 + console.log("Cache hit for", filename); 60 + return cachedContent; 61 + } 33 62 let content = null; 34 63 const blob = await this.client.getBlob(filename); 35 64 if (blob.is_binary) { ··· 37 66 } else { 38 67 content = blob.contents; 39 68 } 69 + this.fileCache?.set(filename, content); 40 70 return content; 41 71 } 42 72 ··· 60 90 61 91 async get404() { 62 92 if (this.notFoundFilepath) { 63 - const content = await this.getFileContent(this.notFoundFilepath); 93 + const fullPath = joinurl( 94 + this.baseDir, 95 + trimLeadingSlash(this.notFoundFilepath) 96 + ); 97 + const content = await this.getFileContent(fullPath); 64 98 if (!content) { 65 - console.warn("'Not found' file not found", this.notFoundFilepath); 99 + console.warn("'Not found' file not found", fullPath); 66 100 return { status: 404, content: "Not Found", contentType: "text/plain" }; 67 101 } 68 102 return { ··· 72 106 }; 73 107 } 74 108 return { status: 404, content: "Not Found", contentType: "text/plain" }; 109 + } 110 + 111 + async clearCache() { 112 + if (!this.fileCache) { 113 + console.log("No cache to clear for", this.ownerDid, this.repoName); 114 + return; 115 + } 116 + console.log("Clearing cache for", this.ownerDid, this.repoName); 117 + this.fileCache.clear(); 75 118 } 76 119 }
+6 -2
src/worker.js
··· 2 2 import { Config } from "./config.js"; 3 3 import configObj from "../config.worker.example.json"; // must be set at build time 4 4 5 + const config = new Config(configObj); 6 + if (config.cache) { 7 + throw new Error("Cache is not supported in worker mode"); 8 + } 9 + 5 10 export default { 6 11 async fetch(request, env, ctx) { 7 - const config = new Config(configObj); 8 - const handler = await Handler.fromConfig(config); 9 12 const url = new URL(request.url); 10 13 const host = url.host; 11 14 const path = url.pathname; 15 + const handler = await Handler.fromConfig(config); 12 16 const { status, content, contentType } = await handler.handleRequest({ 13 17 host, 14 18 path,