Static site hosting via tangled

Compare changes

Choose any two refs to compare.

-16
test/knot-client-test.js
···
-
import { createUnsignedClient } from "../src/knot-client.js";
-
-
const OWNER_DID = "did:plc:p572wxnsuoogcrhlfrlizlrb";
-
const REPO_NAME = "tangled-pages-example";
-
const KNOT_DOMAIN = "knot.gracekind.net";
-
-
const client = createUnsignedClient(KNOT_DOMAIN);
-
-
const blob = await client.blob(
-
OWNER_DID,
-
REPO_NAME,
-
"main",
-
"public/index.html"
-
);
-
-
console.log(blob);
+3
bin/tangled-pages.js
···
+
#!/usr/bin/env node
+
+
import "../src/server.js";
+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"
+
}
+
}
+157 -21
package-lock.json
···
"name": "tangled-pages",
"version": "1.0.0",
"dependencies": {
-
"dotenv": "^17.2.1",
"express": "^5.1.0",
"nodemon": "^3.1.10",
"wrangler": "^4.33.0",
-
"yaml": "^2.8.1"
+
"yargs": "^18.0.0"
+
},
+
"bin": {
+
"tangled-pages": "bin/tangled-pages.js"
}
},
"node_modules/@cloudflare/kv-asset-handler": {
···
"node": ">=0.4.0"
},
+
"node_modules/ansi-regex": {
+
"version": "6.2.0",
+
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz",
+
"integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==",
+
"license": "MIT",
+
"engines": {
+
"node": ">=12"
+
},
+
"funding": {
+
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
+
}
+
},
+
"node_modules/ansi-styles": {
+
"version": "6.2.1",
+
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+
"license": "MIT",
+
"engines": {
+
"node": ">=12"
+
},
+
"funding": {
+
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
+
}
+
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
···
"fsevents": "~2.3.2"
},
+
"node_modules/cliui": {
+
"version": "9.0.1",
+
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+
"license": "ISC",
+
"dependencies": {
+
"string-width": "^7.2.0",
+
"strip-ansi": "^7.1.0",
+
"wrap-ansi": "^9.0.0"
+
},
+
"engines": {
+
"node": ">=20"
+
}
+
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
···
"node": ">=8"
},
-
"node_modules/dotenv": {
-
"version": "17.2.1",
-
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
-
"integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==",
-
"license": "BSD-2-Clause",
-
"engines": {
-
"node": ">=12"
-
},
-
"funding": {
-
"url": "https://dotenvx.com"
-
}
-
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
···
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
+
"node_modules/emoji-regex": {
+
"version": "10.4.0",
+
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
+
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
+
"license": "MIT"
+
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
···
"@esbuild/win32-x64": "0.25.4"
},
+
"node_modules/escalade": {
+
"version": "3.2.0",
+
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+
"license": "MIT",
+
"engines": {
+
"node": ">=6"
+
}
+
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
···
"url": "https://github.com/sponsors/ljharb"
},
+
"node_modules/get-caller-file": {
+
"version": "2.0.5",
+
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+
"license": "ISC",
+
"engines": {
+
"node": "6.* || 8.* || >= 10.*"
+
}
+
},
+
"node_modules/get-east-asian-width": {
+
"version": "1.3.0",
+
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz",
+
"integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==",
+
"license": "MIT",
+
"engines": {
+
"node": ">=18"
+
},
+
"funding": {
+
"url": "https://github.com/sponsors/sindresorhus"
+
}
+
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
···
"npm": ">=6"
},
+
"node_modules/string-width": {
+
"version": "7.2.0",
+
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+
"license": "MIT",
+
"dependencies": {
+
"emoji-regex": "^10.3.0",
+
"get-east-asian-width": "^1.0.0",
+
"strip-ansi": "^7.1.0"
+
},
+
"engines": {
+
"node": ">=18"
+
},
+
"funding": {
+
"url": "https://github.com/sponsors/sindresorhus"
+
}
+
},
+
"node_modules/strip-ansi": {
+
"version": "7.1.0",
+
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+
"license": "MIT",
+
"dependencies": {
+
"ansi-regex": "^6.0.1"
+
},
+
"engines": {
+
"node": ">=12"
+
},
+
"funding": {
+
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
+
}
+
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
···
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
"license": "MIT"
},
+
"node_modules/wrap-ansi": {
+
"version": "9.0.0",
+
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz",
+
"integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==",
+
"license": "MIT",
+
"dependencies": {
+
"ansi-styles": "^6.2.1",
+
"string-width": "^7.0.0",
+
"strip-ansi": "^7.1.0"
+
},
+
"engines": {
+
"node": ">=18"
+
},
+
"funding": {
+
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+
}
+
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
···
},
-
"node_modules/yaml": {
-
"version": "2.8.1",
-
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
-
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
+
"node_modules/y18n": {
+
"version": "5.0.8",
+
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
-
"bin": {
-
"yaml": "bin.mjs"
+
"engines": {
+
"node": ">=10"
+
}
+
},
+
"node_modules/yargs": {
+
"version": "18.0.0",
+
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
+
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
+
"license": "MIT",
+
"dependencies": {
+
"cliui": "^9.0.1",
+
"escalade": "^3.1.1",
+
"get-caller-file": "^2.0.5",
+
"string-width": "^7.2.0",
+
"y18n": "^5.0.5",
+
"yargs-parser": "^22.0.0"
},
"engines": {
-
"node": ">= 14.6"
+
"node": "^20.19.0 || ^22.12.0 || >=23"
+
}
+
},
+
"node_modules/yargs-parser": {
+
"version": "22.0.0",
+
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+
"license": "ISC",
+
"engines": {
+
"node": "^20.19.0 || ^22.12.0 || >=23"
},
"node_modules/youch": {
+2
package.json
···
},
"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": {
+26
src/helpers.js
···
+
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":
+4
wrangler.toml
···
main = "src/worker.js"
compatibility_flags = [ "nodejs_compat" ]
compatibility_date = "2024-09-23"
+
+
+
[observability.logs]
+
enabled = true
+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"
+
}
}
+2 -1
config.example.json
···
"branch": "main",
"baseDir": "/public",
"notFoundFilepath": "/404.html"
-
}
+
},
+
"cache": true
}
+24 -4
src/knot-event-listener.js
···
import EventEmitter from "node:events";
export class KnotEventListener extends EventEmitter {
-
constructor({ knotDomain }) {
+
constructor({ knotDomain, reconnectTimeout = 10000 }) {
super();
this.knotDomain = knotDomain;
+
this.reconnectTimeout = reconnectTimeout;
+
this.connection = null;
}
async start() {
-
const ws = new WebSocket(`wss://${this.knotDomain}/events`);
-
ws.onmessage = (event) => this.handleMessage(event);
+
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) => {
-
ws.onopen = () => {
+
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();
+
}
+
}
}
+8
config.multiple.example.json
···
"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,
+30 -2
src/config.js
···
+
class SiteConfig {
+
constructor({
+
tangledUrl,
+
knotDomain,
+
ownerDid,
+
repoName,
+
branch,
+
baseDir,
+
notFoundFilepath,
+
}) {
+
if (tangledUrl) {
+
if ([ownerDid, repoName].some((v) => !!v)) {
+
throw new Error("Cannot use ownerDid and repoName with url");
+
}
+
}
+
this.tangledUrl = tangledUrl;
+
this.ownerDid = ownerDid;
+
this.repoName = repoName;
+
this.knotDomain = knotDomain;
+
this.branch = branch;
+
this.baseDir = baseDir;
+
this.notFoundFilepath = notFoundFilepath;
+
}
+
}
+
export class Config {
constructor({ site, sites, subdomainOffset, cache = false }) {
-
this.site = site;
-
this.sites = sites;
+
if (site && sites) {
+
throw new Error("Cannot use both site and sites in config");
+
}
+
this.site = site ? new SiteConfig(site) : null;
+
this.sites = sites ? sites.map((site) => new SiteConfig(site)) : null;
this.subdomainOffset = subdomainOffset;
this.cache = cache;
}
+2
README.md
···
When `cache: false`, the server fetches files from the repo on every request, so it might be slow.
+
This library fetches html from the repo directly, so there's no build step. As a workaround, you can add a commit hook to build your site locally and include the built files in your repo (or as a git submodule).
+
## To-do
- support `cache: true` in workers
+13 -6
src/knot-client.js
···
}
async getBlob(filename) {
-
const url = `https://${this.domain}/${this.ownerDid}/${
-
this.repoName
-
}/blob/${this.branch}/${trimLeadingSlash(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 url = `https://${this.domain}/${this.ownerDid}/${this.repoName}/raw/${
-
this.branch
-
}/${trimLeadingSlash(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",