Static site hosting via tangled

Compare changes

Choose any two refs to compare.

+8 -12
config.worker.example.json
···
{
-
"sites": [
-
{
-
"subdomain": "example",
-
"knotDomain": "knot.gracekind.net",
-
"ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb",
-
"repoName": "tangled-pages-example",
-
"branch": "main",
-
"baseDir": "/public",
-
"notFoundFilepath": "/404.html"
-
}
-
],
-
"subdomainOffset": 3
}
···
{
+
"site": {
+
"ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb",
+
"knotDomain": "knot.gracekind.net",
+
"repoName": "tangled-pages-example",
+
"branch": "main",
+
"baseDir": "/public",
+
"notFoundFilepath": "/404.html"
+
}
}
+24 -4
src/knot-event-listener.js
···
import EventEmitter from "node:events";
export class KnotEventListener extends EventEmitter {
-
constructor({ knotDomain }) {
super();
this.knotDomain = knotDomain;
}
async start() {
-
const ws = new WebSocket(`wss://${this.knotDomain}/events`);
-
ws.onmessage = (event) => this.handleMessage(event);
return new Promise((resolve) => {
-
ws.onopen = () => {
console.log("Knot event listener connected to:", this.knotDomain);
resolve();
};
···
this.emit("refUpdate", event);
}
}
}
···
import EventEmitter from "node:events";
export class KnotEventListener extends EventEmitter {
+
constructor({ knotDomain, reconnectTimeout = 10000 }) {
super();
this.knotDomain = knotDomain;
+
this.reconnectTimeout = reconnectTimeout;
+
this.connection = null;
}
async start() {
+
this.connection = new WebSocket(`wss://${this.knotDomain}/events`);
+
this.connection.onmessage = (event) => this.handleMessage(event);
+
this.connection.onerror = (event) => this.handleError(event);
+
this.connection.onclose = () => this.handleClose();
return new Promise((resolve) => {
+
this.connection.onopen = () => {
console.log("Knot event listener connected to:", this.knotDomain);
resolve();
};
···
this.emit("refUpdate", event);
}
}
+
+
handleError(event) {
+
console.error("Knot event listener error:", event);
+
this.emit("error", event);
+
}
+
+
handleClose() {
+
console.log("Knot event listener closed");
+
this.emit("close");
+
if (this.reconnectTimeout) {
+
setTimeout(() => {
+
console.log("Knot event listener reconnecting...");
+
this.start();
+
}, this.reconnectTimeout).unref();
+
}
+
}
}
+5 -4
src/server.js
···
});
}
-
async start() {
-
this.app.listen(3000, () => {
-
console.log("Server is running on port 3000");
});
this.app.on("error", (error) => {
console.error("Server error:", error);
···
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();
}
main();
···
});
}
+
async start({ port }) {
+
this.app.listen(port, () => {
+
console.log(`Server is running on port ${port}`);
});
this.app.on("error", (error) => {
console.error("Server error:", error);
···
async function main() {
const args = yargs(process.argv.slice(2)).parse();
+
const port = args.port ?? args.p ?? 3000;
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({ port });
}
main();
+8
config.multiple.example.json
···
"branch": "main",
"baseDir": "/public",
"notFoundFilepath": "/404.html"
}
],
"subdomainOffset": 1,
···
"branch": "main",
"baseDir": "/public",
"notFoundFilepath": "/404.html"
+
},
+
{
+
"subdomain": "url-example",
+
"tangledUrl": "https://tangled.sh/@gracekind.net/tangled-pages-example",
+
"tangledUrl:comment": "This will render the same site as above, but it's an example of how to use the tangledUrl field",
+
"branch": "main",
+
"baseDir": "/public",
+
"notFoundFilepath": "/404.html"
}
],
"subdomainOffset": 1,
+12
src/atproto.js
···
return service.serviceEndpoint;
}
async function resolveDid(did) {
if (did.startsWith("did:plc:")) {
const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`);
···
return service.serviceEndpoint;
}
+
export async function resolveHandle(handle) {
+
const params = new URLSearchParams({
+
handle,
+
});
+
const res = await fetch(
+
"https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?" +
+
params.toString()
+
);
+
const data = await res.json();
+
return data.did;
+
}
+
async function resolveDid(did) {
if (did.startsWith("did:plc:")) {
const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`);
+32 -14
src/handler.js
···
import { PagesService } from "./pages-service.js";
import { KnotEventListener } from "./knot-event-listener.js";
-
import { listRecords } from "./atproto.js";
async function getKnotDomain(did, repoName) {
const repos = await listRecords({
···
return repo.value.knot;
}
async function getPagesServiceForSite(siteOptions, config) {
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, config);
···
import { PagesService } from "./pages-service.js";
import { KnotEventListener } from "./knot-event-listener.js";
+
import { listRecords, resolveHandle } from "./atproto.js";
async function getKnotDomain(did, repoName) {
const repos = await listRecords({
···
return repo.value.knot;
}
+
function parseTangledUrl(tangledUrl) {
+
// e.g. https://tangled.sh/@gracekind.net/tangled-pages-example
+
const regex = /^https:\/\/tangled\.sh\/@(.+)\/(.+)$/;
+
const match = tangledUrl.match(regex);
+
if (!match) {
+
throw new Error(`Invalid tangled URL: ${tangledUrl}`);
+
}
+
return {
+
handle: match[1],
+
repoName: match[2],
+
};
+
}
+
async function getPagesServiceForSite(siteOptions, config) {
+
// Fetch repoName and ownerDid if needed
+
let ownerDid = siteOptions.ownerDid;
+
let repoName = siteOptions.repoName;
+
+
if (siteOptions.tangledUrl) {
+
const { handle, repoName: parsedRepoName } = parseTangledUrl(
+
siteOptions.tangledUrl
+
);
+
console.log("Getting ownerDid for", handle);
+
const did = await resolveHandle(handle);
+
ownerDid = did;
+
repoName = parsedRepoName;
+
}
+
// Fetch knot domain if needed
let knotDomain = siteOptions.knotDomain;
if (!knotDomain) {
+
console.log("Getting knot domain for", ownerDid + "/" + repoName);
+
knotDomain = await getKnotDomain(ownerDid, repoName);
}
return new PagesService({
knotDomain,
+
ownerDid,
+
repoName,
branch: siteOptions.branch,
baseDir: siteOptions.baseDir,
notFoundFilepath: siteOptions.notFoundFilepath,
···
}
async function getPagesServiceMap(config) {
const pagesServiceMap = {};
if (config.site) {
pagesServiceMap[""] = await getPagesServiceForSite(config.site, config);
+13 -6
src/knot-client.js
···
}
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",
···
}
async getBlob(filename) {
+
const params = new URLSearchParams({
+
repo: `${this.ownerDid}/${this.repoName}`,
+
path: trimLeadingSlash(filename),
+
ref: this.branch,
+
});
+
const url = `https://${this.domain}/xrpc/sh.tangled.repo.blob?${params}`;
console.log(`[KNOT CLIENT]: GET ${url}`);
const res = await fetch(url);
return await res.json();
}
async getRaw(filename) {
+
const params = new URLSearchParams({
+
repo: `${this.ownerDid}/${this.repoName}`,
+
path: trimLeadingSlash(filename),
+
ref: this.branch,
+
raw: "true",
+
});
+
const url = `https://${this.domain}/xrpc/sh.tangled.repo.blob?${params}`;
console.log(`[KNOT CLIENT]: GET ${url}`);
const res = await fetch(url, {
responseType: "arraybuffer",
+2 -2
src/pages-service.js
···
}
let content = null;
const blob = await this.client.getBlob(filename);
-
if (blob.is_binary) {
content = await this.client.getRaw(filename);
} else {
-
content = blob.contents;
}
if (this.fileCache && content) {
const contentSize = Buffer.isBuffer(content)
···
}
let content = null;
const blob = await this.client.getBlob(filename);
+
if (blob.isBinary) {
content = await this.client.getRaw(filename);
} else {
+
content = blob.content;
}
if (this.fileCache && content) {
const contentSize = Buffer.isBuffer(content)