+2
-1
config.example.json
+2
-1
config.example.json
+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,
+24
-4
src/knot-event-listener.js
+24
-4
src/knot-event-listener.js
···
1
1
import EventEmitter from "node:events";
2
2
3
3
export class KnotEventListener extends EventEmitter {
4
-
constructor({ knotDomain }) {
4
+
constructor({ knotDomain, reconnectTimeout = 10000 }) {
5
5
super();
6
6
this.knotDomain = knotDomain;
7
+
this.reconnectTimeout = reconnectTimeout;
8
+
this.connection = null;
7
9
}
8
10
9
11
async start() {
10
-
const ws = new WebSocket(`wss://${this.knotDomain}/events`);
11
-
ws.onmessage = (event) => this.handleMessage(event);
12
+
this.connection = new WebSocket(`wss://${this.knotDomain}/events`);
13
+
this.connection.onmessage = (event) => this.handleMessage(event);
14
+
this.connection.onerror = (event) => this.handleError(event);
15
+
this.connection.onclose = () => this.handleClose();
12
16
return new Promise((resolve) => {
13
-
ws.onopen = () => {
17
+
this.connection.onopen = () => {
14
18
console.log("Knot event listener connected to:", this.knotDomain);
15
19
resolve();
16
20
};
···
29
33
this.emit("refUpdate", event);
30
34
}
31
35
}
36
+
37
+
handleError(event) {
38
+
console.error("Knot event listener error:", event);
39
+
this.emit("error", event);
40
+
}
41
+
42
+
handleClose() {
43
+
console.log("Knot event listener closed");
44
+
this.emit("close");
45
+
if (this.reconnectTimeout) {
46
+
setTimeout(() => {
47
+
console.log("Knot event listener reconnecting...");
48
+
this.start();
49
+
}, this.reconnectTimeout).unref();
50
+
}
51
+
}
32
52
}
+5
-4
src/server.js
+5
-4
src/server.js
···
22
22
});
23
23
}
24
24
25
-
async start() {
26
-
this.app.listen(3000, () => {
27
-
console.log("Server is running on port 3000");
25
+
async start({ port }) {
26
+
this.app.listen(port, () => {
27
+
console.log(`Server is running on port ${port}`);
28
28
});
29
29
this.app.on("error", (error) => {
30
30
console.error("Server error:", error);
···
34
34
35
35
async function main() {
36
36
const args = yargs(process.argv.slice(2)).parse();
37
+
const port = args.port ?? args.p ?? 3000;
37
38
const configFilepath = args.config || "config.json";
38
39
const config = await Config.fromFile(configFilepath);
39
40
const handler = await Handler.fromConfig(config);
40
41
const server = new Server({
41
42
handler,
42
43
});
43
-
await server.start();
44
+
await server.start({ port });
44
45
}
45
46
46
47
main();
+8
config.multiple.example.json
+8
config.multiple.example.json
···
7
7
"branch": "main",
8
8
"baseDir": "/public",
9
9
"notFoundFilepath": "/404.html"
10
+
},
11
+
{
12
+
"subdomain": "url-example",
13
+
"tangledUrl": "https://tangled.sh/@gracekind.net/tangled-pages-example",
14
+
"tangledUrl:comment": "This will render the same site as above, but it's an example of how to use the tangledUrl field",
15
+
"branch": "main",
16
+
"baseDir": "/public",
17
+
"notFoundFilepath": "/404.html"
10
18
}
11
19
],
12
20
"subdomainOffset": 1,
+12
src/atproto.js
+12
src/atproto.js
···
10
10
return service.serviceEndpoint;
11
11
}
12
12
13
+
export async function resolveHandle(handle) {
14
+
const params = new URLSearchParams({
15
+
handle,
16
+
});
17
+
const res = await fetch(
18
+
"https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?" +
19
+
params.toString()
20
+
);
21
+
const data = await res.json();
22
+
return data.did;
23
+
}
24
+
13
25
async function resolveDid(did) {
14
26
if (did.startsWith("did:plc:")) {
15
27
const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`);
+32
-14
src/handler.js
+32
-14
src/handler.js
···
1
1
import { PagesService } from "./pages-service.js";
2
2
import { KnotEventListener } from "./knot-event-listener.js";
3
-
import { listRecords } from "./atproto.js";
3
+
import { listRecords, resolveHandle } from "./atproto.js";
4
4
5
5
async function getKnotDomain(did, repoName) {
6
6
const repos = await listRecords({
···
14
14
return repo.value.knot;
15
15
}
16
16
17
+
function parseTangledUrl(tangledUrl) {
18
+
// e.g. https://tangled.sh/@gracekind.net/tangled-pages-example
19
+
const regex = /^https:\/\/tangled\.sh\/@(.+)\/(.+)$/;
20
+
const match = tangledUrl.match(regex);
21
+
if (!match) {
22
+
throw new Error(`Invalid tangled URL: ${tangledUrl}`);
23
+
}
24
+
return {
25
+
handle: match[1],
26
+
repoName: match[2],
27
+
};
28
+
}
29
+
17
30
async function getPagesServiceForSite(siteOptions, config) {
31
+
// Fetch repoName and ownerDid if needed
32
+
let ownerDid = siteOptions.ownerDid;
33
+
let repoName = siteOptions.repoName;
34
+
35
+
if (siteOptions.tangledUrl) {
36
+
const { handle, repoName: parsedRepoName } = parseTangledUrl(
37
+
siteOptions.tangledUrl
38
+
);
39
+
console.log("Getting ownerDid for", handle);
40
+
const did = await resolveHandle(handle);
41
+
ownerDid = did;
42
+
repoName = parsedRepoName;
43
+
}
44
+
// Fetch knot domain if needed
18
45
let knotDomain = siteOptions.knotDomain;
19
46
if (!knotDomain) {
20
-
console.log(
21
-
"Getting knot domain for",
22
-
siteOptions.ownerDid + "/" + siteOptions.repoName
23
-
);
24
-
knotDomain = await getKnotDomain(
25
-
siteOptions.ownerDid,
26
-
siteOptions.repoName
27
-
);
47
+
console.log("Getting knot domain for", ownerDid + "/" + repoName);
48
+
knotDomain = await getKnotDomain(ownerDid, repoName);
28
49
}
29
50
return new PagesService({
30
51
knotDomain,
31
-
ownerDid: siteOptions.ownerDid,
32
-
repoName: siteOptions.repoName,
52
+
ownerDid,
53
+
repoName,
33
54
branch: siteOptions.branch,
34
55
baseDir: siteOptions.baseDir,
35
56
notFoundFilepath: siteOptions.notFoundFilepath,
···
38
59
}
39
60
40
61
async function getPagesServiceMap(config) {
41
-
if (config.site && config.sites) {
42
-
throw new Error("Cannot use both site and sites in config");
43
-
}
44
62
const pagesServiceMap = {};
45
63
if (config.site) {
46
64
pagesServiceMap[""] = await getPagesServiceForSite(config.site, config);
+13
-6
src/knot-client.js
+13
-6
src/knot-client.js
···
9
9
}
10
10
11
11
async getBlob(filename) {
12
-
const url = `https://${this.domain}/${this.ownerDid}/${
13
-
this.repoName
14
-
}/blob/${this.branch}/${trimLeadingSlash(filename)}`;
12
+
const params = new URLSearchParams({
13
+
repo: `${this.ownerDid}/${this.repoName}`,
14
+
path: trimLeadingSlash(filename),
15
+
ref: this.branch,
16
+
});
17
+
const url = `https://${this.domain}/xrpc/sh.tangled.repo.blob?${params}`;
15
18
console.log(`[KNOT CLIENT]: GET ${url}`);
16
19
const res = await fetch(url);
17
20
return await res.json();
18
21
}
19
22
20
23
async getRaw(filename) {
21
-
const url = `https://${this.domain}/${this.ownerDid}/${this.repoName}/raw/${
22
-
this.branch
23
-
}/${trimLeadingSlash(filename)}`;
24
+
const params = new URLSearchParams({
25
+
repo: `${this.ownerDid}/${this.repoName}`,
26
+
path: trimLeadingSlash(filename),
27
+
ref: this.branch,
28
+
raw: "true",
29
+
});
30
+
const url = `https://${this.domain}/xrpc/sh.tangled.repo.blob?${params}`;
24
31
console.log(`[KNOT CLIENT]: GET ${url}`);
25
32
const res = await fetch(url, {
26
33
responseType: "arraybuffer",
+2
-2
src/pages-service.js
+2
-2
src/pages-service.js
···
67
67
}
68
68
let content = null;
69
69
const blob = await this.client.getBlob(filename);
70
-
if (blob.is_binary) {
70
+
if (blob.isBinary) {
71
71
content = await this.client.getRaw(filename);
72
72
} else {
73
-
content = blob.contents;
73
+
content = blob.content;
74
74
}
75
75
if (this.fileCache && content) {
76
76
const contentSize = Buffer.isBuffer(content)