+3
-3
README.md
+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
+2
-1
config.example.json
+2
-1
src/config.js
+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
+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
+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
+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
+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,