Static site hosting via tangled

Compare changes

Choose any two refs to compare.

-6
.example.env
··· 1 - # fill in and rename to .env 2 - # note these aren't secrets, just config 3 - KNOT_DOMAIN = "knot.gracekind.net" 4 - OWNER_DID = "did:plc:p572wxnsuoogcrhlfrlizlrb" 5 - REPO_NAME = "tangled-pages-example" 6 - BRANCH = "main"
+3
bin/tangled-pages.js
··· 1 + #!/usr/bin/env node 2 + 3 + import "../src/server.js";
+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 + }
+157 -21
package-lock.json
··· 8 8 "name": "tangled-pages", 9 9 "version": "1.0.0", 10 10 "dependencies": { 11 - "dotenv": "^17.2.1", 12 11 "express": "^5.1.0", 13 12 "nodemon": "^3.1.10", 14 13 "wrangler": "^4.33.0", 15 - "yaml": "^2.8.1" 14 + "yargs": "^18.0.0" 15 + }, 16 + "bin": { 17 + "tangled-pages": "bin/tangled-pages.js" 16 18 } 17 19 }, 18 20 "node_modules/@cloudflare/kv-asset-handler": { ··· 1019 1021 "node": ">=0.4.0" 1020 1022 } 1021 1023 }, 1024 + "node_modules/ansi-regex": { 1025 + "version": "6.2.0", 1026 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", 1027 + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", 1028 + "license": "MIT", 1029 + "engines": { 1030 + "node": ">=12" 1031 + }, 1032 + "funding": { 1033 + "url": "https://github.com/chalk/ansi-regex?sponsor=1" 1034 + } 1035 + }, 1036 + "node_modules/ansi-styles": { 1037 + "version": "6.2.1", 1038 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", 1039 + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", 1040 + "license": "MIT", 1041 + "engines": { 1042 + "node": ">=12" 1043 + }, 1044 + "funding": { 1045 + "url": "https://github.com/chalk/ansi-styles?sponsor=1" 1046 + } 1047 + }, 1022 1048 "node_modules/anymatch": { 1023 1049 "version": "3.1.3", 1024 1050 "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", ··· 1156 1182 "fsevents": "~2.3.2" 1157 1183 } 1158 1184 }, 1185 + "node_modules/cliui": { 1186 + "version": "9.0.1", 1187 + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", 1188 + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", 1189 + "license": "ISC", 1190 + "dependencies": { 1191 + "string-width": "^7.2.0", 1192 + "strip-ansi": "^7.1.0", 1193 + "wrap-ansi": "^9.0.0" 1194 + }, 1195 + "engines": { 1196 + "node": ">=20" 1197 + } 1198 + }, 1159 1199 "node_modules/color": { 1160 1200 "version": "4.2.3", 1161 1201 "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", ··· 1277 1317 "node": ">=8" 1278 1318 } 1279 1319 }, 1280 - "node_modules/dotenv": { 1281 - "version": "17.2.1", 1282 - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", 1283 - "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==", 1284 - "license": "BSD-2-Clause", 1285 - "engines": { 1286 - "node": ">=12" 1287 - }, 1288 - "funding": { 1289 - "url": "https://dotenvx.com" 1290 - } 1291 - }, 1292 1320 "node_modules/dunder-proto": { 1293 1321 "version": "1.0.1", 1294 1322 "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", ··· 1307 1335 "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 1308 1336 "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" 1309 1337 }, 1338 + "node_modules/emoji-regex": { 1339 + "version": "10.4.0", 1340 + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", 1341 + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", 1342 + "license": "MIT" 1343 + }, 1310 1344 "node_modules/encodeurl": { 1311 1345 "version": "2.0.0", 1312 1346 "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", ··· 1391 1425 "@esbuild/win32-x64": "0.25.4" 1392 1426 } 1393 1427 }, 1428 + "node_modules/escalade": { 1429 + "version": "3.2.0", 1430 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", 1431 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", 1432 + "license": "MIT", 1433 + "engines": { 1434 + "node": ">=6" 1435 + } 1436 + }, 1394 1437 "node_modules/escape-html": { 1395 1438 "version": "1.0.3", 1396 1439 "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", ··· 1529 1572 "url": "https://github.com/sponsors/ljharb" 1530 1573 } 1531 1574 }, 1575 + "node_modules/get-caller-file": { 1576 + "version": "2.0.5", 1577 + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 1578 + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", 1579 + "license": "ISC", 1580 + "engines": { 1581 + "node": "6.* || 8.* || >= 10.*" 1582 + } 1583 + }, 1584 + "node_modules/get-east-asian-width": { 1585 + "version": "1.3.0", 1586 + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", 1587 + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", 1588 + "license": "MIT", 1589 + "engines": { 1590 + "node": ">=18" 1591 + }, 1592 + "funding": { 1593 + "url": "https://github.com/sponsors/sindresorhus" 1594 + } 1595 + }, 1532 1596 "node_modules/get-intrinsic": { 1533 1597 "version": "1.3.0", 1534 1598 "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", ··· 2258 2322 "npm": ">=6" 2259 2323 } 2260 2324 }, 2325 + "node_modules/string-width": { 2326 + "version": "7.2.0", 2327 + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", 2328 + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", 2329 + "license": "MIT", 2330 + "dependencies": { 2331 + "emoji-regex": "^10.3.0", 2332 + "get-east-asian-width": "^1.0.0", 2333 + "strip-ansi": "^7.1.0" 2334 + }, 2335 + "engines": { 2336 + "node": ">=18" 2337 + }, 2338 + "funding": { 2339 + "url": "https://github.com/sponsors/sindresorhus" 2340 + } 2341 + }, 2342 + "node_modules/strip-ansi": { 2343 + "version": "7.1.0", 2344 + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", 2345 + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", 2346 + "license": "MIT", 2347 + "dependencies": { 2348 + "ansi-regex": "^6.0.1" 2349 + }, 2350 + "engines": { 2351 + "node": ">=12" 2352 + }, 2353 + "funding": { 2354 + "url": "https://github.com/chalk/strip-ansi?sponsor=1" 2355 + } 2356 + }, 2261 2357 "node_modules/supports-color": { 2262 2358 "version": "5.5.0", 2263 2359 "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", ··· 2429 2525 "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", 2430 2526 "license": "MIT" 2431 2527 }, 2528 + "node_modules/wrap-ansi": { 2529 + "version": "9.0.0", 2530 + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", 2531 + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", 2532 + "license": "MIT", 2533 + "dependencies": { 2534 + "ansi-styles": "^6.2.1", 2535 + "string-width": "^7.0.0", 2536 + "strip-ansi": "^7.1.0" 2537 + }, 2538 + "engines": { 2539 + "node": ">=18" 2540 + }, 2541 + "funding": { 2542 + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" 2543 + } 2544 + }, 2432 2545 "node_modules/wrappy": { 2433 2546 "version": "1.0.2", 2434 2547 "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", ··· 2455 2568 } 2456 2569 } 2457 2570 }, 2458 - "node_modules/yaml": { 2459 - "version": "2.8.1", 2460 - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", 2461 - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", 2571 + "node_modules/y18n": { 2572 + "version": "5.0.8", 2573 + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", 2574 + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", 2462 2575 "license": "ISC", 2463 - "bin": { 2464 - "yaml": "bin.mjs" 2576 + "engines": { 2577 + "node": ">=10" 2578 + } 2579 + }, 2580 + "node_modules/yargs": { 2581 + "version": "18.0.0", 2582 + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", 2583 + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", 2584 + "license": "MIT", 2585 + "dependencies": { 2586 + "cliui": "^9.0.1", 2587 + "escalade": "^3.1.1", 2588 + "get-caller-file": "^2.0.5", 2589 + "string-width": "^7.2.0", 2590 + "y18n": "^5.0.5", 2591 + "yargs-parser": "^22.0.0" 2465 2592 }, 2466 2593 "engines": { 2467 - "node": ">= 14.6" 2594 + "node": "^20.19.0 || ^22.12.0 || >=23" 2595 + } 2596 + }, 2597 + "node_modules/yargs-parser": { 2598 + "version": "22.0.0", 2599 + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", 2600 + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", 2601 + "license": "ISC", 2602 + "engines": { 2603 + "node": "^20.19.0 || ^22.12.0 || >=23" 2468 2604 } 2469 2605 }, 2470 2606 "node_modules/youch": {
+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": {
+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":
+4
wrangler.toml
··· 2 2 main = "src/worker.js" 3 3 compatibility_flags = [ "nodejs_compat" ] 4 4 compatibility_date = "2024-09-23" 5 + 6 + 7 + [observability.logs] 8 + enabled = true
+8 -12
config.worker.example.json
··· 1 1 { 2 - "sites": [ 3 - { 4 - "subdomain": "example", 5 - "knotDomain": "knot.gracekind.net", 6 - "ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb", 7 - "repoName": "tangled-pages-example", 8 - "branch": "main", 9 - "baseDir": "/public", 10 - "notFoundFilepath": "/404.html" 11 - } 12 - ], 13 - "subdomainOffset": 3 2 + "site": { 3 + "ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb", 4 + "knotDomain": "knot.gracekind.net", 5 + "repoName": "tangled-pages-example", 6 + "branch": "main", 7 + "baseDir": "/public", 8 + "notFoundFilepath": "/404.html" 9 + } 14 10 }
+2 -1
config.example.json
··· 5 5 "branch": "main", 6 6 "baseDir": "/public", 7 7 "notFoundFilepath": "/404.html" 8 - } 8 + }, 9 + "cache": true 9 10 }
+6 -2
src/worker.js
··· 2 2 import { Config } from "./config.js"; 3 3 import configObj from "../config.worker.example.json"; // must be set at build time 4 4 5 + const config = new Config(configObj); 6 + if (config.cache) { 7 + throw new Error("Cache is not supported in worker mode"); 8 + } 9 + 5 10 export default { 6 11 async fetch(request, env, ctx) { 7 - const config = new Config(configObj); 8 - const handler = await Handler.fromConfig(config); 9 12 const url = new URL(request.url); 10 13 const host = url.host; 11 14 const path = url.pathname; 15 + const handler = await Handler.fromConfig(config); 12 16 const { status, content, contentType } = await handler.handleRequest({ 13 17 host, 14 18 path,
+24 -4
src/knot-event-listener.js
··· 1 1 import EventEmitter from "node:events"; 2 2 3 3 export class KnotEventListener extends EventEmitter { 4 - constructor({ knotDomain }) { 4 + constructor({ knotDomain, reconnectTimeout = 10000 }) { 5 5 super(); 6 6 this.knotDomain = knotDomain; 7 + this.reconnectTimeout = reconnectTimeout; 8 + this.connection = null; 7 9 } 8 10 9 11 async start() { 10 - const ws = new WebSocket(`wss://${this.knotDomain}/events`); 11 - ws.onmessage = (event) => this.handleMessage(event); 12 + this.connection = new WebSocket(`wss://${this.knotDomain}/events`); 13 + this.connection.onmessage = (event) => this.handleMessage(event); 14 + this.connection.onerror = (event) => this.handleError(event); 15 + this.connection.onclose = () => this.handleClose(); 12 16 return new Promise((resolve) => { 13 - ws.onopen = () => { 17 + this.connection.onopen = () => { 14 18 console.log("Knot event listener connected to:", this.knotDomain); 15 19 resolve(); 16 20 }; ··· 29 33 this.emit("refUpdate", event); 30 34 } 31 35 } 36 + 37 + handleError(event) { 38 + console.error("Knot event listener error:", event); 39 + this.emit("error", event); 40 + } 41 + 42 + handleClose() { 43 + console.log("Knot event listener closed"); 44 + this.emit("close"); 45 + if (this.reconnectTimeout) { 46 + setTimeout(() => { 47 + console.log("Knot event listener reconnecting..."); 48 + this.start(); 49 + }, this.reconnectTimeout).unref(); 50 + } 51 + } 32 52 }
+5 -4
src/server.js
··· 22 22 }); 23 23 } 24 24 25 - async start() { 26 - this.app.listen(3000, () => { 27 - console.log("Server is running on port 3000"); 25 + async start({ port }) { 26 + this.app.listen(port, () => { 27 + console.log(`Server is running on port ${port}`); 28 28 }); 29 29 this.app.on("error", (error) => { 30 30 console.error("Server error:", error); ··· 34 34 35 35 async function main() { 36 36 const args = yargs(process.argv.slice(2)).parse(); 37 + const port = args.port ?? args.p ?? 3000; 37 38 const configFilepath = args.config || "config.json"; 38 39 const config = await Config.fromFile(configFilepath); 39 40 const handler = await Handler.fromConfig(config); 40 41 const server = new Server({ 41 42 handler, 42 43 }); 43 - await server.start(); 44 + await server.start({ port }); 44 45 } 45 46 46 47 main();
+8
config.multiple.example.json
··· 7 7 "branch": "main", 8 8 "baseDir": "/public", 9 9 "notFoundFilepath": "/404.html" 10 + }, 11 + { 12 + "subdomain": "url-example", 13 + "tangledUrl": "https://tangled.sh/@gracekind.net/tangled-pages-example", 14 + "tangledUrl:comment": "This will render the same site as above, but it's an example of how to use the tangledUrl field", 15 + "branch": "main", 16 + "baseDir": "/public", 17 + "notFoundFilepath": "/404.html" 10 18 } 11 19 ], 12 20 "subdomainOffset": 1,
+30 -2
src/config.js
··· 1 + class SiteConfig { 2 + constructor({ 3 + tangledUrl, 4 + knotDomain, 5 + ownerDid, 6 + repoName, 7 + branch, 8 + baseDir, 9 + notFoundFilepath, 10 + }) { 11 + if (tangledUrl) { 12 + if ([ownerDid, repoName].some((v) => !!v)) { 13 + throw new Error("Cannot use ownerDid and repoName with url"); 14 + } 15 + } 16 + this.tangledUrl = tangledUrl; 17 + this.ownerDid = ownerDid; 18 + this.repoName = repoName; 19 + this.knotDomain = knotDomain; 20 + this.branch = branch; 21 + this.baseDir = baseDir; 22 + this.notFoundFilepath = notFoundFilepath; 23 + } 24 + } 25 + 1 26 export class Config { 2 27 constructor({ site, sites, subdomainOffset, cache = false }) { 3 - this.site = site; 4 - this.sites = sites; 28 + if (site && sites) { 29 + throw new Error("Cannot use both site and sites in config"); 30 + } 31 + this.site = site ? new SiteConfig(site) : null; 32 + this.sites = sites ? sites.map((site) => new SiteConfig(site)) : null; 5 33 this.subdomainOffset = subdomainOffset; 6 34 this.cache = cache; 7 35 }
+2
README.md
··· 53 53 54 54 When `cache: false`, the server fetches files from the repo on every request, so it might be slow. 55 55 56 + 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). 57 + 56 58 ## To-do 57 59 58 60 - support `cache: true` in workers