From cd67fac4e85eac5d6bebfddf43c4fcf8e30fc2e5 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Thu, 28 Aug 2025 02:33:22 -0500 Subject: [PATCH] Caching for server --- README.md | 6 ++--- config.example.json | 3 ++- src/config.js | 3 ++- src/handler.js | 43 ++++++++++++++++++++++++++++++---- src/knot-event-listener.js | 31 +++++++++++++++++++++++++ src/pages-service.js | 47 ++++++++++++++++++++++++++++++++++++-- src/worker.js | 8 +++++-- 7 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 src/knot-event-listener.js diff --git a/README.md b/README.md index c592503..6c7be4e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ Create a `config.json` for your site(s). "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) } ``` @@ -35,5 +36,4 @@ You can see an example of a hosted site [here](https://tangled-pages-example.gra ## 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. diff --git a/config.example.json b/config.example.json index edc7d6e..8283763 100644 --- a/config.example.json +++ b/config.example.json @@ -5,5 +5,6 @@ "branch": "main", "baseDir": "/public", "notFoundFilepath": "/404.html" - } + }, + "cache": true } diff --git a/src/config.js b/src/config.js index aeb784c..d8180a1 100644 --- a/src/config.js +++ b/src/config.js @@ -1,8 +1,9 @@ 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) { diff --git a/src/handler.js b/src/handler.js index 4ff6492..6a380f2 100644 --- a/src/handler.js +++ b/src/handler.js @@ -1,4 +1,5 @@ import { PagesService } from "./pages-service.js"; +import { KnotEventListener } from "./knot-event-listener.js"; import { listRecords } from "./atproto.js"; async function getKnotDomain(did, repoName) { @@ -13,7 +14,7 @@ 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( @@ -32,6 +33,7 @@ async function getPagesServiceForSite(siteOptions) { branch: siteOptions.branch, baseDir: siteOptions.baseDir, notFoundFilepath: siteOptions.notFoundFilepath, + cache: config.cache, }); } @@ -41,25 +43,56 @@ async function getPagesServiceMap(config) { } 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 }) { diff --git a/src/knot-event-listener.js b/src/knot-event-listener.js new file mode 100644 index 0000000..9eeb258 --- /dev/null +++ b/src/knot-event-listener.js @@ -0,0 +1,31 @@ +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); + } + } +} diff --git a/src/pages-service.js b/src/pages-service.js index 315dffe..aabff6d 100644 --- a/src/pages-service.js +++ b/src/pages-service.js @@ -6,6 +6,24 @@ import { } 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, @@ -14,6 +32,7 @@ export class PagesService { branch = "main", baseDir = "/", notFoundFilepath = null, + cache, }) { this.knotDomain = knotDomain; this.ownerDid = ownerDid; @@ -27,9 +46,19 @@ export class PagesService { 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) { @@ -37,6 +66,7 @@ export class PagesService { } else { content = blob.contents; } + this.fileCache?.set(filename, content); return content; } @@ -60,9 +90,13 @@ export class PagesService { 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 { @@ -73,4 +107,13 @@ export class PagesService { } 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(); + } } diff --git a/src/worker.js b/src/worker.js index d620349..369d2ec 100644 --- a/src/worker.js +++ b/src/worker.js @@ -2,13 +2,17 @@ import { Handler } from "./handler.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, -- 2.51.2