Static site hosting via tangled

Caching for server

+3 -3
README.md
···
"branch": "main", // optional, defaults to main
"baseDir": "/public", // optional, defaults to the repo root
"notFoundFilepath": "/404.html" // optional, defaults to text 404
-
}
+
},
+
"cache": true // server only, not supported in workers (yet)
}
```
···
## Limitations
-
The server fetches files from the repo on request, so it might be slow.
-
In the future, we could cache the files and use a CI to clear the cache as needed.
+
When `cache: false`, the server fetches files from the repo on every request, so it might be slow.
+2 -1
config.example.json
···
"branch": "main",
"baseDir": "/public",
"notFoundFilepath": "/404.html"
-
}
+
},
+
"cache": true
}
+2 -1
src/config.js
···
export class Config {
-
constructor({ site, sites, subdomainOffset }) {
+
constructor({ site, sites, subdomainOffset, cache = false }) {
this.site = site;
this.sites = sites;
this.subdomainOffset = subdomainOffset;
+
this.cache = cache;
}
static async fromFile(filepath) {
+38 -5
src/handler.js
···
import { PagesService } from "./pages-service.js";
+
import { KnotEventListener } from "./knot-event-listener.js";
import { listRecords } from "./atproto.js";
async function getKnotDomain(did, repoName) {
···
return repo.value.knot;
}
-
async function getPagesServiceForSite(siteOptions) {
+
async function getPagesServiceForSite(siteOptions, config) {
let knotDomain = siteOptions.knotDomain;
if (!knotDomain) {
console.log(
···
branch: siteOptions.branch,
baseDir: siteOptions.baseDir,
notFoundFilepath: siteOptions.notFoundFilepath,
+
cache: config.cache,
});
}
···
}
const pagesServiceMap = {};
if (config.site) {
-
pagesServiceMap[""] = await getPagesServiceForSite(config.site);
+
pagesServiceMap[""] = await getPagesServiceForSite(config.site, config);
}
if (config.sites) {
for (const site of config.sites) {
-
pagesServiceMap[site.subdomain] = await getPagesServiceForSite(site);
+
pagesServiceMap[site.subdomain] = await getPagesServiceForSite(
+
site,
+
config
+
);
}
}
return pagesServiceMap;
}
export class Handler {
-
constructor({ config, pagesServiceMap }) {
+
constructor({ config, pagesServiceMap, knotEventListeners }) {
this.config = config;
this.pagesServiceMap = pagesServiceMap;
+
this.knotEventListeners = knotEventListeners;
+
+
for (const knotEventListener of this.knotEventListeners) {
+
knotEventListener.on("refUpdate", (event) => this.handleRefUpdate(event));
+
}
}
static async fromConfig(config) {
const pagesServiceMap = await getPagesServiceMap(config);
-
return new Handler({ config, pagesServiceMap });
+
const knotDomains = new Set(
+
Object.values(pagesServiceMap).map((ps) => ps.knotDomain)
+
);
+
const knotEventListeners = [];
+
if (config.cache) {
+
for (const knotDomain of knotDomains) {
+
const eventListener = new KnotEventListener({
+
knotDomain,
+
});
+
await eventListener.start();
+
knotEventListeners.push(eventListener);
+
}
+
}
+
return new Handler({ config, pagesServiceMap, knotEventListeners });
+
}
+
+
handleRefUpdate(event) {
+
const { ownerDid, repoName } = event.details;
+
const pagesService = Object.values(this.pagesServiceMap).find(
+
(ps) => ps.ownerDid === ownerDid && ps.repoName === repoName
+
);
+
if (pagesService) {
+
pagesService.clearCache();
+
}
}
async handleRequest({ host, path }) {
+31
src/knot-event-listener.js
···
+
import EventEmitter from "node:events";
+
+
export class KnotEventListener extends EventEmitter {
+
constructor({ knotDomain }) {
+
super();
+
this.knotDomain = knotDomain;
+
}
+
+
async start() {
+
const ws = new WebSocket(`wss://${this.knotDomain}/events`);
+
ws.onmessage = (event) => this.handleMessage(event);
+
return new Promise((resolve) => {
+
ws.onopen = () => {
+
resolve();
+
};
+
});
+
}
+
+
async handleMessage(event) {
+
const data = JSON.parse(event.data);
+
if (data.nsid === "sh.tangled.git.refUpdate") {
+
const event = {
+
details: {
+
ownerDid: data.event.repoDid,
+
repoName: data.event.repoName,
+
},
+
};
+
this.emit("refUpdate", event);
+
}
+
}
+
}
+45 -2
src/pages-service.js
···
} from "./helpers.js";
import { KnotClient } from "./knot-client.js";
+
class FileCache {
+
constructor() {
+
this.cache = new Map();
+
}
+
+
get(filename) {
+
return this.cache.get(filename) ?? null;
+
}
+
+
set(filename, content) {
+
this.cache.set(filename, content);
+
}
+
+
clear() {
+
this.cache.clear();
+
}
+
}
+
export class PagesService {
constructor({
knotDomain,
···
branch = "main",
baseDir = "/",
notFoundFilepath = null,
+
cache,
}) {
this.knotDomain = knotDomain;
this.ownerDid = ownerDid;
···
repoName,
branch,
});
+
this.fileCache = null;
+
if (cache) {
+
console.log("Enabling cache for", this.ownerDid, this.repoName);
+
this.fileCache = new FileCache();
+
}
}
async getFileContent(filename) {
+
const cachedContent = this.fileCache?.get(filename);
+
if (cachedContent) {
+
console.log("Cache hit for", filename);
+
return cachedContent;
+
}
let content = null;
const blob = await this.client.getBlob(filename);
if (blob.is_binary) {
···
} else {
content = blob.contents;
}
+
this.fileCache?.set(filename, content);
return content;
}
···
async get404() {
if (this.notFoundFilepath) {
-
const content = await this.getFileContent(this.notFoundFilepath);
+
const fullPath = joinurl(
+
this.baseDir,
+
trimLeadingSlash(this.notFoundFilepath)
+
);
+
const content = await this.getFileContent(fullPath);
if (!content) {
-
console.warn("'Not found' file not found", this.notFoundFilepath);
+
console.warn("'Not found' file not found", fullPath);
return { status: 404, content: "Not Found", contentType: "text/plain" };
}
return {
···
};
}
return { status: 404, content: "Not Found", contentType: "text/plain" };
+
}
+
+
async clearCache() {
+
if (!this.fileCache) {
+
console.log("No cache to clear for", this.ownerDid, this.repoName);
+
return;
+
}
+
console.log("Clearing cache for", this.ownerDid, this.repoName);
+
this.fileCache.clear();
}
}
+6 -2
src/worker.js
···
import { Config } from "./config.js";
import configObj from "../config.worker.example.json"; // must be set at build time
+
const config = new Config(configObj);
+
if (config.cache) {
+
throw new Error("Cache is not supported in worker mode");
+
}
+
export default {
async fetch(request, env, ctx) {
-
const config = new Config(configObj);
-
const handler = await Handler.fromConfig(config);
const url = new URL(request.url);
const host = url.host;
const path = url.pathname;
+
const handler = await Handler.fromConfig(config);
const { status, content, contentType } = await handler.handleRequest({
host,
path,