+4
-4
README.md
+4
-4
README.md
···
10
10
```json
11
11
{
12
12
"site": {
13
-
"knotDomain": "knot.gracekind.net",
14
13
"ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb",
15
14
"repoName": "tangled-pages-example",
16
-
"branch": "main",
17
-
"baseDir": "/public", // optional
18
-
"notFoundFilepath": "/404.html" // optional
15
+
"knotDomain": "knot.gracekind.net", // optional, will look up via ownerDid
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
19
}
20
20
}
21
21
```
-2
config.example.json
-2
config.example.json
+1
-2
config.multiple.example.json
+1
-2
config.multiple.example.json
-11
config.worker.example.json
-11
config.worker.example.json
+2
package.json
+2
package.json
···
7
7
},
8
8
"scripts": {
9
9
"start": "npx tangled-pages --config config.example.json",
10
+
"dev": "nodemon --watch src --watch config.example.json --exec 'node src/server.js --config config.example.json'",
11
+
"dev:multiple": "nodemon --watch src --watch config.multiple.example.json --exec 'node src/server.js --config config.multiple.example.json'",
10
12
"dev:worker": "wrangler dev --port 3000"
11
13
},
12
14
"dependencies": {
+52
src/atproto.js
+52
src/atproto.js
···
1
+
const PDS_SERVICE_ID = "#atproto_pds";
2
+
3
+
async function getServiceEndpointFromDidDoc(didDoc) {
4
+
const service = didDoc.service.find((s) => s.id === PDS_SERVICE_ID);
5
+
if (!service) {
6
+
throw new Error(
7
+
`No PDS service found in DID doc ${JSON.stringify(didDoc)}`
8
+
);
9
+
}
10
+
return service.serviceEndpoint;
11
+
}
12
+
13
+
async function resolveDid(did) {
14
+
if (did.startsWith("did:plc:")) {
15
+
const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`);
16
+
const didDoc = await res.json();
17
+
return didDoc;
18
+
} else if (did.startsWith("did:web:")) {
19
+
const website = did.split(":")[2];
20
+
const res = await fetch(`https://${website}/.well-known/did.json`);
21
+
const didDoc = await res.json();
22
+
return didDoc;
23
+
} else {
24
+
throw new Error(`Unsupported DID: ${did}`);
25
+
}
26
+
}
27
+
28
+
async function getServiceEndpointForDid(did) {
29
+
const didDoc = await resolveDid(did);
30
+
return getServiceEndpointFromDidDoc(didDoc);
31
+
}
32
+
33
+
export async function listRecords({ did, collection }) {
34
+
const serviceEndpoint = await getServiceEndpointForDid(did);
35
+
let cursor = "";
36
+
const records = [];
37
+
do {
38
+
const res = await fetch(
39
+
`${serviceEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=100&cursor=${cursor}`
40
+
);
41
+
const data = await res.json();
42
+
const recordsWithAuthor = data.records.map((record) => {
43
+
return {
44
+
...record,
45
+
author: did,
46
+
};
47
+
});
48
+
records.push(...recordsWithAuthor);
49
+
cursor = data.cursor;
50
+
} while (cursor);
51
+
return records;
52
+
}
+13
src/config.js
+13
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) {
9
+
const fs = await import("fs");
10
+
const config = JSON.parse(fs.readFileSync(filepath, "utf8"));
11
+
return new Config(config);
12
+
}
13
+
}
+90
src/handler.js
+90
src/handler.js
···
1
+
import { PagesService } from "./pages-service.js";
2
+
import { listRecords } from "./atproto.js";
3
+
4
+
async function getKnotDomain(did, repoName) {
5
+
const repos = await listRecords({
6
+
did,
7
+
collection: "sh.tangled.repo",
8
+
});
9
+
const repo = repos.find((r) => r.value.name === repoName);
10
+
if (!repo) {
11
+
throw new Error(`Repo ${repoName} not found for did ${did}`);
12
+
}
13
+
return repo.value.knot;
14
+
}
15
+
16
+
async function getPagesServiceForSite(siteOptions) {
17
+
let knotDomain = siteOptions.knotDomain;
18
+
if (!knotDomain) {
19
+
console.log(
20
+
"Getting knot domain for",
21
+
siteOptions.ownerDid + "/" + siteOptions.repoName
22
+
);
23
+
knotDomain = await getKnotDomain(
24
+
siteOptions.ownerDid,
25
+
siteOptions.repoName
26
+
);
27
+
}
28
+
return new PagesService({
29
+
knotDomain,
30
+
ownerDid: siteOptions.ownerDid,
31
+
repoName: siteOptions.repoName,
32
+
branch: siteOptions.branch,
33
+
baseDir: siteOptions.baseDir,
34
+
notFoundFilepath: siteOptions.notFoundFilepath,
35
+
});
36
+
}
37
+
38
+
async function getPagesServiceMap(config) {
39
+
if (config.site && config.sites) {
40
+
throw new Error("Cannot use both site and sites in config");
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 }) {
66
+
// Single site mode
67
+
const singleSite = this.pagesServiceMap[""];
68
+
if (singleSite) {
69
+
const { status, content, contentType } = await singleSite.getPage(path);
70
+
return { status, content, contentType };
71
+
}
72
+
// Multi site mode
73
+
const subdomainOffset = this.config.subdomainOffset ?? 2;
74
+
const subdomain = host.split(".").at((subdomainOffset + 1) * -1);
75
+
if (!subdomain) {
76
+
return {
77
+
status: 200,
78
+
content: "Tangled pages is running! Sites can be found at subdomains.",
79
+
contentType: "text/plain",
80
+
};
81
+
}
82
+
const matchingSite = this.pagesServiceMap[subdomain];
83
+
if (matchingSite) {
84
+
const { status, content, contentType } = await matchingSite.getPage(path);
85
+
return { status, content, contentType };
86
+
}
87
+
console.log("No matching site found for subdomain", subdomain);
88
+
return { status: 404, content: "Not Found", contentType: "text/plain" };
89
+
}
90
+
}
+26
src/helpers.js
+26
src/helpers.js
···
1
+
export function joinurl(...segments) {
2
+
let url = segments[0];
3
+
for (const segment of segments.slice(1)) {
4
+
if (url.endsWith("/") && segment.startsWith("/")) {
5
+
url = url.slice(0, -1) + segment;
6
+
} else if (!url.endsWith("/") && !segment.startsWith("/")) {
7
+
url = url + "/" + segment;
8
+
} else {
9
+
url = url + segment;
10
+
}
11
+
}
12
+
return url;
13
+
}
14
+
15
+
export function extname(filename) {
16
+
if (!filename.includes(".")) {
17
+
return "";
18
+
}
19
+
return "." + filename.split(".").pop();
20
+
}
21
+
22
+
export function getContentTypeForFilename(filename) {
23
+
const extension = extname(filename).toLowerCase();
24
+
return getContentTypeForExtension(extension);
25
+
}
26
+
1
27
export function getContentTypeForExtension(extension, fallback = "text/plain") {
2
28
switch (extension) {
3
29
case ".html":
+31
src/knot-client.js
+31
src/knot-client.js
···
1
+
import { trimLeadingSlash } from "./helpers.js";
2
+
3
+
export class KnotClient {
4
+
constructor({ domain, ownerDid, repoName, branch }) {
5
+
this.domain = domain;
6
+
this.ownerDid = ownerDid;
7
+
this.repoName = repoName;
8
+
this.branch = branch;
9
+
}
10
+
11
+
async getBlob(filename) {
12
+
const url = `https://${this.domain}/${this.ownerDid}/${
13
+
this.repoName
14
+
}/blob/${this.branch}/${trimLeadingSlash(filename)}`;
15
+
console.log(`[KNOT CLIENT]: GET ${url}`);
16
+
const res = await fetch(url);
17
+
return await res.json();
18
+
}
19
+
20
+
async getRaw(filename) {
21
+
const url = `https://${this.domain}/${this.ownerDid}/${this.repoName}/raw/${
22
+
this.branch
23
+
}/${trimLeadingSlash(filename)}`;
24
+
console.log(`[KNOT CLIENT]: GET ${url}`);
25
+
const res = await fetch(url, {
26
+
responseType: "arraybuffer",
27
+
});
28
+
const arrayBuffer = await res.arrayBuffer();
29
+
return Buffer.from(arrayBuffer);
30
+
}
31
+
}
+23
-51
src/pages-service.js
+23
-51
src/pages-service.js
···
1
-
import { getContentTypeForExtension, trimLeadingSlash } from "./helpers.js";
2
-
import path from "node:path";
3
-
4
-
// Helpers
5
-
6
-
function getContentTypeForFilename(filename) {
7
-
const extension = path.extname(filename).toLowerCase();
8
-
return getContentTypeForExtension(extension);
9
-
}
10
-
11
-
class KnotClient {
12
-
constructor({ domain, ownerDid, repoName, branch }) {
13
-
this.domain = domain;
14
-
this.ownerDid = ownerDid;
15
-
this.repoName = repoName;
16
-
this.branch = branch;
17
-
}
18
-
19
-
async getBlob(filename) {
20
-
const url = `https://${this.domain}/${this.ownerDid}/${
21
-
this.repoName
22
-
}/blob/${this.branch}/${trimLeadingSlash(filename)}`;
23
-
const res = await fetch(url);
24
-
return await res.json();
25
-
}
26
-
27
-
async getRaw(filename) {
28
-
const url = `https://${this.domain}/${this.ownerDid}/${this.repoName}/raw/${
29
-
this.branch
30
-
}/${trimLeadingSlash(filename)}`;
31
-
const res = await fetch(url, {
32
-
responseType: "arraybuffer",
33
-
});
34
-
const arrayBuffer = await res.arrayBuffer();
35
-
return Buffer.from(arrayBuffer);
36
-
}
37
-
}
1
+
import {
2
+
getContentTypeForFilename,
3
+
trimLeadingSlash,
4
+
extname,
5
+
joinurl,
6
+
} from "./helpers.js";
7
+
import { KnotClient } from "./knot-client.js";
38
8
39
-
class PagesService {
9
+
export class PagesService {
40
10
constructor({
41
-
domain,
11
+
knotDomain,
42
12
ownerDid,
43
13
repoName,
44
-
branch,
14
+
branch = "main",
45
15
baseDir = "/",
46
16
notFoundFilepath = null,
47
17
}) {
48
-
this.domain = domain;
18
+
this.knotDomain = knotDomain;
49
19
this.ownerDid = ownerDid;
50
20
this.repoName = repoName;
51
21
this.branch = branch;
52
22
this.baseDir = baseDir;
53
23
this.notFoundFilepath = notFoundFilepath;
54
24
this.client = new KnotClient({
55
-
domain: domain,
56
-
ownerDid: ownerDid,
57
-
repoName: repoName,
58
-
branch: branch,
25
+
domain: knotDomain,
26
+
ownerDid,
27
+
repoName,
28
+
branch,
59
29
});
60
30
}
61
31
···
72
42
73
43
async getPage(route) {
74
44
let filePath = route;
75
-
const extension = path.extname(filePath);
76
-
if (extension === "") {
77
-
filePath = path.join(filePath, "index.html");
45
+
const extension = extname(filePath);
46
+
if (!extension) {
47
+
filePath = joinurl(filePath, "index.html");
78
48
}
79
-
const fullPath = path.join(this.baseDir, trimLeadingSlash(filePath));
49
+
const fullPath = joinurl(this.baseDir, trimLeadingSlash(filePath));
80
50
const content = await this.getFileContent(fullPath);
81
51
if (!content) {
82
52
return this.get404();
···
91
61
async get404() {
92
62
if (this.notFoundFilepath) {
93
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
+
}
94
68
return {
95
69
status: 404,
96
70
content,
···
100
74
return { status: 404, content: "Not Found", contentType: "text/plain" };
101
75
}
102
76
}
103
-
104
-
export default PagesService;
+19
-54
src/server.js
+19
-54
src/server.js
···
1
-
import PagesService from "./pages-service.js";
1
+
import { PagesService } from "./pages-service.js";
2
2
import express from "express";
3
-
import fs from "fs";
4
3
import yargs from "yargs";
4
+
import { Handler } from "./handler.js";
5
+
import { Config } from "./config.js";
5
6
6
-
class Config {
7
-
constructor({ site, sites, subdomainOffset }) {
8
-
this.site = site;
9
-
this.sites = sites || [];
10
-
this.subdomainOffset = subdomainOffset;
11
-
}
7
+
class Server {
8
+
constructor({ handler }) {
9
+
this.handler = handler;
12
10
13
-
static fromFile(filepath) {
14
-
const config = JSON.parse(fs.readFileSync(filepath, "utf8"));
15
-
return new Config(config);
16
-
}
17
-
}
18
-
19
-
class Server {
20
-
constructor(config) {
21
-
this.config = config;
22
11
this.app = express();
23
12
24
-
if (config.subdomainOffset) {
25
-
this.app.set("subdomain offset", config.subdomainOffset);
26
-
}
27
-
28
13
this.app.get("/{*any}", async (req, res) => {
29
-
// Single site mode
30
-
if (this.config.site) {
31
-
return this.handleSiteRequest(req, res, this.config.site);
32
-
}
33
-
// Multi site mode
34
-
const subdomain = req.subdomains.at(-1);
35
-
if (!subdomain) {
36
-
return res.status(200).send("Tangled pages is running!");
37
-
}
38
-
const matchingSite = this.config.sites.find(
39
-
(site) => site.subdomain === subdomain
14
+
const host = req.hostname;
15
+
const path = req.path;
16
+
const { status, content, contentType } = await this.handler.handleRequest(
17
+
{
18
+
host,
19
+
path,
20
+
}
40
21
);
41
-
if (matchingSite) {
42
-
await this.handleSiteRequest(req, res, matchingSite);
43
-
} else {
44
-
console.log("No matching site found for subdomain", subdomain);
45
-
return res.status(404).send("Not Found");
46
-
}
47
-
});
48
-
}
49
-
50
-
async handleSiteRequest(req, res, site) {
51
-
const route = req.path;
52
-
const pagesService = new PagesService({
53
-
domain: site.knotDomain,
54
-
ownerDid: site.ownerDid,
55
-
repoName: site.repoName,
56
-
branch: site.branch,
57
-
baseDir: site.baseDir,
58
-
notFoundFilepath: site.notFoundFilepath,
22
+
res.status(status).set("Content-Type", contentType).send(content);
59
23
});
60
-
const { status, content, contentType } = await pagesService.getPage(route);
61
-
res.status(status).set("Content-Type", contentType).send(content);
62
24
}
63
25
64
26
async start() {
···
74
36
async function main() {
75
37
const args = yargs(process.argv.slice(2)).parse();
76
38
const configFilepath = args.config || "config.json";
77
-
const config = Config.fromFile(configFilepath);
78
-
const server = new Server(config);
39
+
const config = await Config.fromFile(configFilepath);
40
+
const handler = await Handler.fromConfig(config);
41
+
const server = new Server({
42
+
handler,
43
+
});
79
44
await server.start();
80
45
}
81
46
+15
-37
src/worker.js
+15
-37
src/worker.js
···
1
-
import PagesService from "./pages-service.js";
2
-
import config from "../config.worker.example.json"; // must be set at build time
3
-
4
-
async function handleSiteRequest(request, site) {
5
-
const url = new URL(request.url);
6
-
const route = url.pathname;
7
-
const pagesService = new PagesService({
8
-
domain: site.knotDomain,
9
-
ownerDid: site.ownerDid,
10
-
repoName: site.repoName,
11
-
branch: site.branch,
12
-
baseDir: site.baseDir,
13
-
notFoundFilepath: site.notFoundFilepath,
14
-
});
15
-
const { status, content, contentType } = await pagesService.getPage(route);
16
-
return new Response(content, {
17
-
status,
18
-
headers: { "Content-Type": contentType },
19
-
});
20
-
}
1
+
import { Handler } from "./handler.js";
2
+
import { Config } from "./config.js";
3
+
import configObj from "../config.example.json"; // must be set at build time
21
4
22
5
export default {
23
6
async fetch(request, env, ctx) {
24
-
// Single site mode
25
-
if (config.site) {
26
-
return handleSiteRequest(request, config.site);
27
-
}
28
-
// Multi site mode
7
+
const config = new Config(configObj);
8
+
const handler = await Handler.fromConfig(config);
29
9
const url = new URL(request.url);
30
-
const subdomainOffset = config.subdomainOffset ?? 2;
31
-
const subdomain = url.host.split(".").at((subdomainOffset + 1) * -1);
32
-
if (!subdomain) {
33
-
return new Response("Tangled pages is running!", { status: 200 });
34
-
}
35
-
const matchingSite = config.sites?.find(
36
-
(site) => site.subdomain === subdomain
37
-
);
38
-
if (matchingSite) {
39
-
return handleSiteRequest(request, matchingSite);
40
-
}
41
-
return new Response("Not Found", { status: 404 });
10
+
const host = url.host;
11
+
const path = url.pathname;
12
+
const { status, content, contentType } = await handler.handleRequest({
13
+
host,
14
+
path,
15
+
});
16
+
return new Response(content, {
17
+
status,
18
+
headers: { "Content-Type": contentType },
19
+
});
42
20
},
43
21
};