Static site hosting via tangled

Use shared handler

+4 -4
README.md
···
```json
{
"site": {
-
"knotDomain": "knot.gracekind.net",
"ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb",
"repoName": "tangled-pages-example",
-
"branch": "main",
-
"baseDir": "/public", // optional
-
"notFoundFilepath": "/404.html" // optional
}
}
```
···
```json
{
"site": {
"ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb",
"repoName": "tangled-pages-example",
+
"knotDomain": "knot.gracekind.net", // optional, will look up via ownerDid
+
"branch": "main", // optional, defaults to main
+
"baseDir": "/public", // optional, defaults to the repo root
+
"notFoundFilepath": "/404.html" // optional, defaults to text 404
}
}
```
-2
config.example.json
···
{
"site": {
-
"name": "tangled-pages-example",
-
"knotDomain": "knot.gracekind.net",
"ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb",
"repoName": "tangled-pages-example",
"branch": "main",
···
{
"site": {
"ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb",
"repoName": "tangled-pages-example",
"branch": "main",
+1 -2
config.multiple.example.json
···
{
"sites": [
{
-
"subdomain": "tangled-pages-example",
-
"knotDomain": "knot.gracekind.net",
"ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb",
"repoName": "tangled-pages-example",
"branch": "main",
···
{
"sites": [
{
+
"subdomain": "example",
"ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb",
"repoName": "tangled-pages-example",
"branch": "main",
-11
config.worker.example.json
···
-
{
-
"site": {
-
"name": "tangled-pages-example",
-
"knotDomain": "knot.gracekind.net",
-
"ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb",
-
"repoName": "tangled-pages-example",
-
"branch": "main",
-
"baseDir": "/public",
-
"notFoundFilepath": "/404.html"
-
}
-
}
···
+2
package.json
···
},
"scripts": {
"start": "npx tangled-pages --config config.example.json",
"dev:worker": "wrangler dev --port 3000"
},
"dependencies": {
···
},
"scripts": {
"start": "npx tangled-pages --config config.example.json",
+
"dev": "nodemon --watch src --watch config.example.json --exec 'node src/server.js --config config.example.json'",
+
"dev:multiple": "nodemon --watch src --watch config.multiple.example.json --exec 'node src/server.js --config config.multiple.example.json'",
"dev:worker": "wrangler dev --port 3000"
},
"dependencies": {
+52
src/atproto.js
···
···
+
const PDS_SERVICE_ID = "#atproto_pds";
+
+
async function getServiceEndpointFromDidDoc(didDoc) {
+
const service = didDoc.service.find((s) => s.id === PDS_SERVICE_ID);
+
if (!service) {
+
throw new Error(
+
`No PDS service found in DID doc ${JSON.stringify(didDoc)}`
+
);
+
}
+
return service.serviceEndpoint;
+
}
+
+
async function resolveDid(did) {
+
if (did.startsWith("did:plc:")) {
+
const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`);
+
const didDoc = await res.json();
+
return didDoc;
+
} else if (did.startsWith("did:web:")) {
+
const website = did.split(":")[2];
+
const res = await fetch(`https://${website}/.well-known/did.json`);
+
const didDoc = await res.json();
+
return didDoc;
+
} else {
+
throw new Error(`Unsupported DID: ${did}`);
+
}
+
}
+
+
async function getServiceEndpointForDid(did) {
+
const didDoc = await resolveDid(did);
+
return getServiceEndpointFromDidDoc(didDoc);
+
}
+
+
export async function listRecords({ did, collection }) {
+
const serviceEndpoint = await getServiceEndpointForDid(did);
+
let cursor = "";
+
const records = [];
+
do {
+
const res = await fetch(
+
`${serviceEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=100&cursor=${cursor}`
+
);
+
const data = await res.json();
+
const recordsWithAuthor = data.records.map((record) => {
+
return {
+
...record,
+
author: did,
+
};
+
});
+
records.push(...recordsWithAuthor);
+
cursor = data.cursor;
+
} while (cursor);
+
return records;
+
}
+13
src/config.js
···
···
+
export class Config {
+
constructor({ site, sites, subdomainOffset }) {
+
this.site = site;
+
this.sites = sites;
+
this.subdomainOffset = subdomainOffset;
+
}
+
+
static async fromFile(filepath) {
+
const fs = await import("fs");
+
const config = JSON.parse(fs.readFileSync(filepath, "utf8"));
+
return new Config(config);
+
}
+
}
+90
src/handler.js
···
···
+
import { PagesService } from "./pages-service.js";
+
import { listRecords } from "./atproto.js";
+
+
async function getKnotDomain(did, repoName) {
+
const repos = await listRecords({
+
did,
+
collection: "sh.tangled.repo",
+
});
+
const repo = repos.find((r) => r.value.name === repoName);
+
if (!repo) {
+
throw new Error(`Repo ${repoName} not found for did ${did}`);
+
}
+
return repo.value.knot;
+
}
+
+
async function getPagesServiceForSite(siteOptions) {
+
let knotDomain = siteOptions.knotDomain;
+
if (!knotDomain) {
+
console.log(
+
"Getting knot domain for",
+
siteOptions.ownerDid + "/" + siteOptions.repoName
+
);
+
knotDomain = await getKnotDomain(
+
siteOptions.ownerDid,
+
siteOptions.repoName
+
);
+
}
+
return new PagesService({
+
knotDomain,
+
ownerDid: siteOptions.ownerDid,
+
repoName: siteOptions.repoName,
+
branch: siteOptions.branch,
+
baseDir: siteOptions.baseDir,
+
notFoundFilepath: siteOptions.notFoundFilepath,
+
});
+
}
+
+
async function getPagesServiceMap(config) {
+
if (config.site && config.sites) {
+
throw new Error("Cannot use both site and sites in config");
+
}
+
const pagesServiceMap = {};
+
if (config.site) {
+
pagesServiceMap[""] = await getPagesServiceForSite(config.site);
+
}
+
if (config.sites) {
+
for (const site of config.sites) {
+
pagesServiceMap[site.subdomain] = await getPagesServiceForSite(site);
+
}
+
}
+
return pagesServiceMap;
+
}
+
+
export class Handler {
+
constructor({ config, pagesServiceMap }) {
+
this.config = config;
+
this.pagesServiceMap = pagesServiceMap;
+
}
+
+
static async fromConfig(config) {
+
const pagesServiceMap = await getPagesServiceMap(config);
+
return new Handler({ config, pagesServiceMap });
+
}
+
+
async handleRequest({ host, path }) {
+
// Single site mode
+
const singleSite = this.pagesServiceMap[""];
+
if (singleSite) {
+
const { status, content, contentType } = await singleSite.getPage(path);
+
return { status, content, contentType };
+
}
+
// Multi site mode
+
const subdomainOffset = this.config.subdomainOffset ?? 2;
+
const subdomain = host.split(".").at((subdomainOffset + 1) * -1);
+
if (!subdomain) {
+
return {
+
status: 200,
+
content: "Tangled pages is running! Sites can be found at subdomains.",
+
contentType: "text/plain",
+
};
+
}
+
const matchingSite = this.pagesServiceMap[subdomain];
+
if (matchingSite) {
+
const { status, content, contentType } = await matchingSite.getPage(path);
+
return { status, content, contentType };
+
}
+
console.log("No matching site found for subdomain", subdomain);
+
return { status: 404, content: "Not Found", contentType: "text/plain" };
+
}
+
}
+26
src/helpers.js
···
export function getContentTypeForExtension(extension, fallback = "text/plain") {
switch (extension) {
case ".html":
···
+
export function joinurl(...segments) {
+
let url = segments[0];
+
for (const segment of segments.slice(1)) {
+
if (url.endsWith("/") && segment.startsWith("/")) {
+
url = url.slice(0, -1) + segment;
+
} else if (!url.endsWith("/") && !segment.startsWith("/")) {
+
url = url + "/" + segment;
+
} else {
+
url = url + segment;
+
}
+
}
+
return url;
+
}
+
+
export function extname(filename) {
+
if (!filename.includes(".")) {
+
return "";
+
}
+
return "." + filename.split(".").pop();
+
}
+
+
export function getContentTypeForFilename(filename) {
+
const extension = extname(filename).toLowerCase();
+
return getContentTypeForExtension(extension);
+
}
+
export function getContentTypeForExtension(extension, fallback = "text/plain") {
switch (extension) {
case ".html":
+31
src/knot-client.js
···
···
+
import { trimLeadingSlash } from "./helpers.js";
+
+
export class KnotClient {
+
constructor({ domain, ownerDid, repoName, branch }) {
+
this.domain = domain;
+
this.ownerDid = ownerDid;
+
this.repoName = repoName;
+
this.branch = branch;
+
}
+
+
async getBlob(filename) {
+
const url = `https://${this.domain}/${this.ownerDid}/${
+
this.repoName
+
}/blob/${this.branch}/${trimLeadingSlash(filename)}`;
+
console.log(`[KNOT CLIENT]: GET ${url}`);
+
const res = await fetch(url);
+
return await res.json();
+
}
+
+
async getRaw(filename) {
+
const url = `https://${this.domain}/${this.ownerDid}/${this.repoName}/raw/${
+
this.branch
+
}/${trimLeadingSlash(filename)}`;
+
console.log(`[KNOT CLIENT]: GET ${url}`);
+
const res = await fetch(url, {
+
responseType: "arraybuffer",
+
});
+
const arrayBuffer = await res.arrayBuffer();
+
return Buffer.from(arrayBuffer);
+
}
+
}
+23 -51
src/pages-service.js
···
-
import { getContentTypeForExtension, trimLeadingSlash } from "./helpers.js";
-
import path from "node:path";
-
-
// Helpers
-
-
function getContentTypeForFilename(filename) {
-
const extension = path.extname(filename).toLowerCase();
-
return getContentTypeForExtension(extension);
-
}
-
-
class KnotClient {
-
constructor({ domain, ownerDid, repoName, branch }) {
-
this.domain = domain;
-
this.ownerDid = ownerDid;
-
this.repoName = repoName;
-
this.branch = branch;
-
}
-
-
async getBlob(filename) {
-
const url = `https://${this.domain}/${this.ownerDid}/${
-
this.repoName
-
}/blob/${this.branch}/${trimLeadingSlash(filename)}`;
-
const res = await fetch(url);
-
return await res.json();
-
}
-
-
async getRaw(filename) {
-
const url = `https://${this.domain}/${this.ownerDid}/${this.repoName}/raw/${
-
this.branch
-
}/${trimLeadingSlash(filename)}`;
-
const res = await fetch(url, {
-
responseType: "arraybuffer",
-
});
-
const arrayBuffer = await res.arrayBuffer();
-
return Buffer.from(arrayBuffer);
-
}
-
}
-
class PagesService {
constructor({
-
domain,
ownerDid,
repoName,
-
branch,
baseDir = "/",
notFoundFilepath = null,
}) {
-
this.domain = domain;
this.ownerDid = ownerDid;
this.repoName = repoName;
this.branch = branch;
this.baseDir = baseDir;
this.notFoundFilepath = notFoundFilepath;
this.client = new KnotClient({
-
domain: domain,
-
ownerDid: ownerDid,
-
repoName: repoName,
-
branch: branch,
});
}
···
async getPage(route) {
let filePath = route;
-
const extension = path.extname(filePath);
-
if (extension === "") {
-
filePath = path.join(filePath, "index.html");
}
-
const fullPath = path.join(this.baseDir, trimLeadingSlash(filePath));
const content = await this.getFileContent(fullPath);
if (!content) {
return this.get404();
···
async get404() {
if (this.notFoundFilepath) {
const content = await this.getFileContent(this.notFoundFilepath);
return {
status: 404,
content,
···
return { status: 404, content: "Not Found", contentType: "text/plain" };
}
}
-
-
export default PagesService;
···
+
import {
+
getContentTypeForFilename,
+
trimLeadingSlash,
+
extname,
+
joinurl,
+
} from "./helpers.js";
+
import { KnotClient } from "./knot-client.js";
+
export class PagesService {
constructor({
+
knotDomain,
ownerDid,
repoName,
+
branch = "main",
baseDir = "/",
notFoundFilepath = null,
}) {
+
this.knotDomain = knotDomain;
this.ownerDid = ownerDid;
this.repoName = repoName;
this.branch = branch;
this.baseDir = baseDir;
this.notFoundFilepath = notFoundFilepath;
this.client = new KnotClient({
+
domain: knotDomain,
+
ownerDid,
+
repoName,
+
branch,
});
}
···
async getPage(route) {
let filePath = route;
+
const extension = extname(filePath);
+
if (!extension) {
+
filePath = joinurl(filePath, "index.html");
}
+
const fullPath = joinurl(this.baseDir, trimLeadingSlash(filePath));
const content = await this.getFileContent(fullPath);
if (!content) {
return this.get404();
···
async get404() {
if (this.notFoundFilepath) {
const content = await this.getFileContent(this.notFoundFilepath);
+
if (!content) {
+
console.warn("'Not found' file not found", this.notFoundFilepath);
+
return { status: 404, content: "Not Found", contentType: "text/plain" };
+
}
return {
status: 404,
content,
···
return { status: 404, content: "Not Found", contentType: "text/plain" };
}
}
+19 -54
src/server.js
···
-
import PagesService from "./pages-service.js";
import express from "express";
-
import fs from "fs";
import yargs from "yargs";
-
class Config {
-
constructor({ site, sites, subdomainOffset }) {
-
this.site = site;
-
this.sites = sites || [];
-
this.subdomainOffset = subdomainOffset;
-
}
-
static fromFile(filepath) {
-
const config = JSON.parse(fs.readFileSync(filepath, "utf8"));
-
return new Config(config);
-
}
-
}
-
-
class Server {
-
constructor(config) {
-
this.config = config;
this.app = express();
-
if (config.subdomainOffset) {
-
this.app.set("subdomain offset", config.subdomainOffset);
-
}
-
this.app.get("/{*any}", async (req, res) => {
-
// Single site mode
-
if (this.config.site) {
-
return this.handleSiteRequest(req, res, this.config.site);
-
}
-
// Multi site mode
-
const subdomain = req.subdomains.at(-1);
-
if (!subdomain) {
-
return res.status(200).send("Tangled pages is running!");
-
}
-
const matchingSite = this.config.sites.find(
-
(site) => site.subdomain === subdomain
);
-
if (matchingSite) {
-
await this.handleSiteRequest(req, res, matchingSite);
-
} else {
-
console.log("No matching site found for subdomain", subdomain);
-
return res.status(404).send("Not Found");
-
}
-
});
-
}
-
-
async handleSiteRequest(req, res, site) {
-
const route = req.path;
-
const pagesService = new PagesService({
-
domain: site.knotDomain,
-
ownerDid: site.ownerDid,
-
repoName: site.repoName,
-
branch: site.branch,
-
baseDir: site.baseDir,
-
notFoundFilepath: site.notFoundFilepath,
});
-
const { status, content, contentType } = await pagesService.getPage(route);
-
res.status(status).set("Content-Type", contentType).send(content);
}
async start() {
···
async function main() {
const args = yargs(process.argv.slice(2)).parse();
const configFilepath = args.config || "config.json";
-
const config = Config.fromFile(configFilepath);
-
const server = new Server(config);
await server.start();
}
···
+
import { PagesService } from "./pages-service.js";
import express from "express";
import yargs from "yargs";
+
import { Handler } from "./handler.js";
+
import { Config } from "./config.js";
+
class Server {
+
constructor({ handler }) {
+
this.handler = handler;
this.app = express();
this.app.get("/{*any}", async (req, res) => {
+
const host = req.hostname;
+
const path = req.path;
+
const { status, content, contentType } = await this.handler.handleRequest(
+
{
+
host,
+
path,
+
}
);
+
res.status(status).set("Content-Type", contentType).send(content);
});
}
async start() {
···
async function main() {
const args = yargs(process.argv.slice(2)).parse();
const configFilepath = args.config || "config.json";
+
const config = await Config.fromFile(configFilepath);
+
const handler = await Handler.fromConfig(config);
+
const server = new Server({
+
handler,
+
});
await server.start();
}
+15 -37
src/worker.js
···
-
import PagesService from "./pages-service.js";
-
import config from "../config.worker.example.json"; // must be set at build time
-
-
async function handleSiteRequest(request, site) {
-
const url = new URL(request.url);
-
const route = url.pathname;
-
const pagesService = new PagesService({
-
domain: site.knotDomain,
-
ownerDid: site.ownerDid,
-
repoName: site.repoName,
-
branch: site.branch,
-
baseDir: site.baseDir,
-
notFoundFilepath: site.notFoundFilepath,
-
});
-
const { status, content, contentType } = await pagesService.getPage(route);
-
return new Response(content, {
-
status,
-
headers: { "Content-Type": contentType },
-
});
-
}
export default {
async fetch(request, env, ctx) {
-
// Single site mode
-
if (config.site) {
-
return handleSiteRequest(request, config.site);
-
}
-
// Multi site mode
const url = new URL(request.url);
-
const subdomainOffset = config.subdomainOffset ?? 2;
-
const subdomain = url.host.split(".").at((subdomainOffset + 1) * -1);
-
if (!subdomain) {
-
return new Response("Tangled pages is running!", { status: 200 });
-
}
-
const matchingSite = config.sites?.find(
-
(site) => site.subdomain === subdomain
-
);
-
if (matchingSite) {
-
return handleSiteRequest(request, matchingSite);
-
}
-
return new Response("Not Found", { status: 404 });
},
};
···
+
import { Handler } from "./handler.js";
+
import { Config } from "./config.js";
+
import configObj from "../config.example.json"; // must be set at build time
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 { status, content, contentType } = await handler.handleRequest({
+
host,
+
path,
+
});
+
return new Response(content, {
+
status,
+
headers: { "Content-Type": contentType },
+
});
},
};