Static site hosting via tangled

Caching for server

+3 -3
README.md
··· 16 "branch": "main", // optional, defaults to main 17 "baseDir": "/public", // optional, defaults to the repo root 18 "notFoundFilepath": "/404.html" // optional, defaults to text 404 19 - } 20 } 21 ``` 22 ··· 35 36 ## Limitations 37 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.
··· 16 "branch": "main", // optional, defaults to main 17 "baseDir": "/public", // optional, defaults to the repo root 18 "notFoundFilepath": "/404.html" // optional, defaults to text 404 19 + }, 20 + "cache": true // server only, not supported in workers (yet) 21 } 22 ``` 23 ··· 36 37 ## Limitations 38 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 "branch": "main", 6 "baseDir": "/public", 7 "notFoundFilepath": "/404.html" 8 - } 9 }
··· 5 "branch": "main", 6 "baseDir": "/public", 7 "notFoundFilepath": "/404.html" 8 + }, 9 + "cache": true 10 }
+2 -1
src/config.js
··· 1 export class Config { 2 - constructor({ site, sites, subdomainOffset }) { 3 this.site = site; 4 this.sites = sites; 5 this.subdomainOffset = subdomainOffset; 6 } 7 8 static async fromFile(filepath) {
··· 1 export class Config { 2 + constructor({ site, sites, subdomainOffset, cache = false }) { 3 this.site = site; 4 this.sites = sites; 5 this.subdomainOffset = subdomainOffset; 6 + this.cache = cache; 7 } 8 9 static async fromFile(filepath) {
+38 -5
src/handler.js
··· 1 import { PagesService } from "./pages-service.js"; 2 import { listRecords } from "./atproto.js"; 3 4 async function getKnotDomain(did, repoName) { ··· 13 return repo.value.knot; 14 } 15 16 - async function getPagesServiceForSite(siteOptions) { 17 let knotDomain = siteOptions.knotDomain; 18 if (!knotDomain) { 19 console.log( ··· 32 branch: siteOptions.branch, 33 baseDir: siteOptions.baseDir, 34 notFoundFilepath: siteOptions.notFoundFilepath, 35 }); 36 } 37 ··· 41 } 42 const pagesServiceMap = {}; 43 if (config.site) { 44 - pagesServiceMap[""] = await getPagesServiceForSite(config.site); 45 } 46 if (config.sites) { 47 for (const site of config.sites) { 48 - pagesServiceMap[site.subdomain] = await getPagesServiceForSite(site); 49 } 50 } 51 return pagesServiceMap; 52 } 53 54 export class Handler { 55 - constructor({ config, pagesServiceMap }) { 56 this.config = config; 57 this.pagesServiceMap = pagesServiceMap; 58 } 59 60 static async fromConfig(config) { 61 const pagesServiceMap = await getPagesServiceMap(config); 62 - return new Handler({ config, pagesServiceMap }); 63 } 64 65 async handleRequest({ host, path }) {
··· 1 import { PagesService } from "./pages-service.js"; 2 + import { KnotEventListener } from "./knot-event-listener.js"; 3 import { listRecords } from "./atproto.js"; 4 5 async function getKnotDomain(did, repoName) { ··· 14 return repo.value.knot; 15 } 16 17 + async function getPagesServiceForSite(siteOptions, config) { 18 let knotDomain = siteOptions.knotDomain; 19 if (!knotDomain) { 20 console.log( ··· 33 branch: siteOptions.branch, 34 baseDir: siteOptions.baseDir, 35 notFoundFilepath: siteOptions.notFoundFilepath, 36 + cache: config.cache, 37 }); 38 } 39 ··· 43 } 44 const pagesServiceMap = {}; 45 if (config.site) { 46 + pagesServiceMap[""] = await getPagesServiceForSite(config.site, config); 47 } 48 if (config.sites) { 49 for (const site of config.sites) { 50 + pagesServiceMap[site.subdomain] = await getPagesServiceForSite( 51 + site, 52 + config 53 + ); 54 } 55 } 56 return pagesServiceMap; 57 } 58 59 export class Handler { 60 + constructor({ config, pagesServiceMap, knotEventListeners }) { 61 this.config = config; 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 + } 68 } 69 70 static async fromConfig(config) { 71 const pagesServiceMap = await getPagesServiceMap(config); 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 + } 96 } 97 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 } from "./helpers.js"; 7 import { KnotClient } from "./knot-client.js"; 8 9 export class PagesService { 10 constructor({ 11 knotDomain, ··· 14 branch = "main", 15 baseDir = "/", 16 notFoundFilepath = null, 17 }) { 18 this.knotDomain = knotDomain; 19 this.ownerDid = ownerDid; ··· 27 repoName, 28 branch, 29 }); 30 } 31 32 async getFileContent(filename) { 33 let content = null; 34 const blob = await this.client.getBlob(filename); 35 if (blob.is_binary) { ··· 37 } else { 38 content = blob.contents; 39 } 40 return content; 41 } 42 ··· 60 61 async get404() { 62 if (this.notFoundFilepath) { 63 - const content = await this.getFileContent(this.notFoundFilepath); 64 if (!content) { 65 - console.warn("'Not found' file not found", this.notFoundFilepath); 66 return { status: 404, content: "Not Found", contentType: "text/plain" }; 67 } 68 return { ··· 72 }; 73 } 74 return { status: 404, content: "Not Found", contentType: "text/plain" }; 75 } 76 }
··· 6 } from "./helpers.js"; 7 import { KnotClient } from "./knot-client.js"; 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 + 27 export class PagesService { 28 constructor({ 29 knotDomain, ··· 32 branch = "main", 33 baseDir = "/", 34 notFoundFilepath = null, 35 + cache, 36 }) { 37 this.knotDomain = knotDomain; 38 this.ownerDid = ownerDid; ··· 46 repoName, 47 branch, 48 }); 49 + this.fileCache = null; 50 + if (cache) { 51 + console.log("Enabling cache for", this.ownerDid, this.repoName); 52 + this.fileCache = new FileCache(); 53 + } 54 } 55 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 + } 62 let content = null; 63 const blob = await this.client.getBlob(filename); 64 if (blob.is_binary) { ··· 66 } else { 67 content = blob.contents; 68 } 69 + this.fileCache?.set(filename, content); 70 return content; 71 } 72 ··· 90 91 async get404() { 92 if (this.notFoundFilepath) { 93 + const fullPath = joinurl( 94 + this.baseDir, 95 + trimLeadingSlash(this.notFoundFilepath) 96 + ); 97 + const content = await this.getFileContent(fullPath); 98 if (!content) { 99 + console.warn("'Not found' file not found", fullPath); 100 return { status: 404, content: "Not Found", contentType: "text/plain" }; 101 } 102 return { ··· 106 }; 107 } 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(); 118 } 119 }
+6 -2
src/worker.js
··· 2 import { Config } from "./config.js"; 3 import configObj from "../config.worker.example.json"; // must be set at build time 4 5 export default { 6 async fetch(request, env, ctx) { 7 - const config = new Config(configObj); 8 - const handler = await Handler.fromConfig(config); 9 const url = new URL(request.url); 10 const host = url.host; 11 const path = url.pathname; 12 const { status, content, contentType } = await handler.handleRequest({ 13 host, 14 path,
··· 2 import { Config } from "./config.js"; 3 import configObj from "../config.worker.example.json"; // must be set at build time 4 5 + const config = new Config(configObj); 6 + if (config.cache) { 7 + throw new Error("Cache is not supported in worker mode"); 8 + } 9 + 10 export default { 11 async fetch(request, env, ctx) { 12 const url = new URL(request.url); 13 const host = url.host; 14 const path = url.pathname; 15 + const handler = await Handler.fromConfig(config); 16 const { status, content, contentType } = await handler.handleRequest({ 17 host, 18 path,