Static site hosting via tangled

Use shared handler

+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
··· 1 1 { 2 2 "site": { 3 - "name": "tangled-pages-example", 4 - "knotDomain": "knot.gracekind.net", 5 3 "ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb", 6 4 "repoName": "tangled-pages-example", 7 5 "branch": "main",
+1 -2
config.multiple.example.json
··· 1 1 { 2 2 "sites": [ 3 3 { 4 - "subdomain": "tangled-pages-example", 5 - "knotDomain": "knot.gracekind.net", 4 + "subdomain": "example", 6 5 "ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb", 7 6 "repoName": "tangled-pages-example", 8 7 "branch": "main",
-11
config.worker.example.json
··· 1 - { 2 - "site": { 3 - "name": "tangled-pages-example", 4 - "knotDomain": "knot.gracekind.net", 5 - "ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb", 6 - "repoName": "tangled-pages-example", 7 - "branch": "main", 8 - "baseDir": "/public", 9 - "notFoundFilepath": "/404.html" 10 - } 11 - }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 };