Static site hosting via tangled

Enable multi site

-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"
···
+18 -19
README.md
··· 3 A simple way to host a website via a tangled repo. 4 You can run it as a cloudflare worker or as an express server. 5 6 - ## Setup 7 8 - Create .env pointing to the repo you want to host: 9 10 ``` 11 - KNOT_DOMAIN=knot.gracekind.net 12 - OWNER_DID=did:plc:p572wxnsuoogcrhlfrlizlrb 13 - REPO_NAME=tangled-pages-example 14 - BRANCH=main 15 - ``` 16 17 - Run server: 18 19 ```bash 20 npm install 21 - npm start 22 - ``` 23 - 24 - ## Config 25 - 26 - You can configure the site by creating a `pages_config.yaml` file in the root of the hosted repo. 27 - 28 - ```yaml 29 - baseDir: "/public" 30 - notFoundFilepath: "404.html" 31 ``` 32 33 ## Limitations 34 35 - It fetches files from the repo on request, so it might be slow. 36 In the future, we could cache the files and use a CI to clear the cache as needed.
··· 3 A simple way to host a website via a tangled repo. 4 You can run it as a cloudflare worker or as an express server. 5 6 + ## Run 7 8 + Create a `config.json` for your site(s). 9 10 + ```json 11 + { 12 + "site": { 13 + "knotDomain": "knot.gracekind.net", 14 + "ownerDid": "did:plc:p572wxnsuoogcrhlfrlizlrb", 15 + "repoName": "tangled-pages-example", 16 + "branch": "main", 17 + "baseDir": "/public", // optional 18 + "notFoundFilepath": "/404.html" // optional 19 + } 20 + } 21 ``` 22 + 23 + See `config.multiple.example.json` for an example of a multi-site config. 24 25 + Then run: 26 27 ```bash 28 npm install 29 + npx tangled-pages --config config.json 30 ``` 31 32 ## Limitations 33 34 + The server fetches files from the repo on request, so it might be slow. 35 In the future, we could cache the files and use a CI to clear the cache as needed.
+3
bin/tangled-pages.js
···
··· 1 + #!/usr/bin/env node 2 + 3 + import "../src/server.js";
+11
config.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 + }
+15
config.multiple.example.json
···
··· 1 + { 2 + "sites": [ 3 + { 4 + "subdomain": "tangled-pages-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": 1, 14 + "subdomainOffset:comment": "Subdomain offset will usually be 1 for localhost, 2 for production" 15 + }
+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 "name": "tangled-pages", 9 "version": "1.0.0", 10 "dependencies": { 11 - "dotenv": "^17.2.1", 12 "express": "^5.1.0", 13 "nodemon": "^3.1.10", 14 "wrangler": "^4.33.0", 15 - "yaml": "^2.8.1" 16 } 17 }, 18 "node_modules/@cloudflare/kv-asset-handler": { ··· 1019 "node": ">=0.4.0" 1020 } 1021 }, 1022 "node_modules/anymatch": { 1023 "version": "3.1.3", 1024 "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", ··· 1156 "fsevents": "~2.3.2" 1157 } 1158 }, 1159 "node_modules/color": { 1160 "version": "4.2.3", 1161 "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", ··· 1277 "node": ">=8" 1278 } 1279 }, 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 "node_modules/dunder-proto": { 1293 "version": "1.0.1", 1294 "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", ··· 1306 "version": "1.1.1", 1307 "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 1308 "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" 1309 }, 1310 "node_modules/encodeurl": { 1311 "version": "2.0.0", ··· 1389 "@esbuild/win32-arm64": "0.25.4", 1390 "@esbuild/win32-ia32": "0.25.4", 1391 "@esbuild/win32-x64": "0.25.4" 1392 } 1393 }, 1394 "node_modules/escape-html": { ··· 1529 "url": "https://github.com/sponsors/ljharb" 1530 } 1531 }, 1532 "node_modules/get-intrinsic": { 1533 "version": "1.3.0", 1534 "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", ··· 2258 "npm": ">=6" 2259 } 2260 }, 2261 "node_modules/supports-color": { 2262 "version": "5.5.0", 2263 "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", ··· 2429 "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", 2430 "license": "MIT" 2431 }, 2432 "node_modules/wrappy": { 2433 "version": "1.0.2", 2434 "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", ··· 2455 } 2456 } 2457 }, 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==", 2462 "license": "ISC", 2463 - "bin": { 2464 - "yaml": "bin.mjs" 2465 }, 2466 "engines": { 2467 - "node": ">= 14.6" 2468 } 2469 }, 2470 "node_modules/youch": {
··· 8 "name": "tangled-pages", 9 "version": "1.0.0", 10 "dependencies": { 11 "express": "^5.1.0", 12 "nodemon": "^3.1.10", 13 "wrangler": "^4.33.0", 14 + "yargs": "^18.0.0" 15 + }, 16 + "bin": { 17 + "tangled-pages": "bin/tangled-pages.js" 18 } 19 }, 20 "node_modules/@cloudflare/kv-asset-handler": { ··· 1021 "node": ">=0.4.0" 1022 } 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 + }, 1048 "node_modules/anymatch": { 1049 "version": "3.1.3", 1050 "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", ··· 1182 "fsevents": "~2.3.2" 1183 } 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 + }, 1199 "node_modules/color": { 1200 "version": "4.2.3", 1201 "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", ··· 1317 "node": ">=8" 1318 } 1319 }, 1320 "node_modules/dunder-proto": { 1321 "version": "1.0.1", 1322 "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", ··· 1334 "version": "1.1.1", 1335 "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 1336 "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" 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 }, 1344 "node_modules/encodeurl": { 1345 "version": "2.0.0", ··· 1423 "@esbuild/win32-arm64": "0.25.4", 1424 "@esbuild/win32-ia32": "0.25.4", 1425 "@esbuild/win32-x64": "0.25.4" 1426 + } 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 }, 1437 "node_modules/escape-html": { ··· 1572 "url": "https://github.com/sponsors/ljharb" 1573 } 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 + }, 1596 "node_modules/get-intrinsic": { 1597 "version": "1.3.0", 1598 "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", ··· 2322 "npm": ">=6" 2323 } 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 + }, 2357 "node_modules/supports-color": { 2358 "version": "5.5.0", 2359 "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", ··· 2525 "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", 2526 "license": "MIT" 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 + }, 2545 "node_modules/wrappy": { 2546 "version": "1.0.2", 2547 "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", ··· 2568 } 2569 } 2570 }, 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==", 2575 "license": "ISC", 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" 2592 }, 2593 "engines": { 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" 2604 } 2605 }, 2606 "node_modules/youch": {
+8 -6
package.json
··· 2 "name": "tangled-pages", 3 "version": "1.0.0", 4 "type": "module", 5 "scripts": { 6 - "start": "npm run dev", 7 - "dev": "npm run dev:worker", 8 - "dev:worker": "wrangler dev --port 3000 --env development", 9 - "dev:express": "nodemon src/server.js" 10 }, 11 "dependencies": { 12 - "dotenv": "^17.2.1", 13 "express": "^5.1.0", 14 "nodemon": "^3.1.10", 15 "wrangler": "^4.33.0", 16 - "yaml": "^2.8.1" 17 } 18 }
··· 2 "name": "tangled-pages", 3 "version": "1.0.0", 4 "type": "module", 5 + "bin": { 6 + "tangled-pages": "bin/tangled-pages.js" 7 + }, 8 "scripts": { 9 + "start": "npm run dev:express", 10 + "dev": "npm run dev:express", 11 + "dev:express": "nodemon src/server.js", 12 + "dev:worker": "wrangler dev --port 3000" 13 }, 14 "dependencies": { 15 "express": "^5.1.0", 16 "nodemon": "^3.1.10", 17 "wrangler": "^4.33.0", 18 + "yargs": "^18.0.0" 19 } 20 }
+8 -97
src/pages-service.js
··· 1 import { getContentTypeForExtension, trimLeadingSlash } from "./helpers.js"; 2 import path from "node:path"; 3 - import yaml from "yaml"; 4 5 // Helpers 6 ··· 9 return getContentTypeForExtension(extension); 10 } 11 12 - class FileCache { 13 - constructor({ expirationSeconds = 60 }) { 14 - this.cache = new Map(); 15 - this.expirationSeconds = expirationSeconds; 16 - } 17 - 18 - get(filename) { 19 - if (this.cache.has(filename)) { 20 - const entry = this.cache.get(filename); 21 - if ( 22 - entry && 23 - entry.timestamp > Date.now() - this.expirationSeconds * 1000 24 - ) { 25 - return entry.content; 26 - } 27 - } 28 - return null; 29 - } 30 - 31 - set(filename, content) { 32 - const timestamp = Date.now(); 33 - this.cache.set(filename, { content, timestamp }); 34 - } 35 - } 36 - 37 - class PagesConfig { 38 - constructor({ baseDir, notFoundFilepath }) { 39 - this.baseDir = baseDir; 40 - this.notFoundFilepath = notFoundFilepath; 41 - } 42 - 43 - static default() { 44 - return new PagesConfig({ 45 - baseDir: "/", 46 - notFoundFilepath: null, 47 - }); 48 - } 49 - 50 - static fromFile(filePath, fileContent) { 51 - if (!filePath.endsWith(".yaml")) { 52 - throw new Error("Config file must be a YAML file"); 53 - } 54 - let configObj = {}; 55 - try { 56 - configObj = yaml.parse(fileContent); 57 - } catch (error) { 58 - throw new Error(`Error parsing YAML file ${filePath}: ${error}`); 59 - } 60 - return new PagesConfig({ 61 - baseDir: configObj.baseDir || "/", 62 - notFoundFilepath: configObj.notFoundFilepath || null, 63 - }); 64 - } 65 - } 66 - 67 class KnotClient { 68 constructor({ domain, ownerDid, repoName, branch }) { 69 this.domain = domain; ··· 98 ownerDid, 99 repoName, 100 branch, 101 - configFilepath = "pages_config.yaml", 102 - fileCacheExpirationSeconds = 10, 103 }) { 104 this.domain = domain; 105 this.ownerDid = ownerDid; 106 this.repoName = repoName; 107 this.branch = branch; 108 - this.configFilepath = configFilepath; 109 - this.fileCache = new FileCache({ 110 - expirationSeconds: fileCacheExpirationSeconds, 111 - }); 112 this.client = new KnotClient({ 113 domain: domain, 114 ownerDid: ownerDid, ··· 117 }); 118 } 119 120 - async getConfig() { 121 - if (!this.__config) { 122 - await this.loadConfig(); 123 - } 124 - return this.__config; 125 - } 126 - 127 - async loadConfig() { 128 - let config = null; 129 - const configFileContent = await this.getFileContent(this.configFilepath); 130 - if (!configFileContent) { 131 - console.warn( 132 - `No config file found at ${this.configFilepath}, using default config` 133 - ); 134 - config = PagesConfig.default(); 135 - } else { 136 - config = PagesConfig.fromFile(this.configFilepath, configFileContent); 137 - } 138 - this.__config = config; 139 - return config; 140 - } 141 - 142 async getFileContent(filename) { 143 - const cachedContent = this.fileCache.get(filename); 144 - if (cachedContent) { 145 - return cachedContent; 146 - } 147 let content = null; 148 const blob = await this.client.getBlob(filename); 149 if (blob.is_binary) { ··· 151 } else { 152 content = blob.contents; 153 } 154 - this.fileCache.set(filename, content); 155 return content; 156 } 157 158 async getPage(route) { 159 - const config = await this.getConfig(); 160 let filePath = route; 161 const extension = path.extname(filePath); 162 if (extension === "") { 163 filePath = path.join(filePath, "index.html"); 164 } 165 - 166 - const fullPath = path.join(config.baseDir, trimLeadingSlash(filePath)); 167 - 168 const content = await this.getFileContent(fullPath); 169 if (!content) { 170 return this.get404(); ··· 177 } 178 179 async get404() { 180 - const { notFoundFilepath } = await this.getConfig(); 181 - if (notFoundFilepath) { 182 - const content = await this.getFileContent(notFoundFilepath); 183 return { 184 status: 404, 185 content, 186 - contentType: getContentTypeForFilename(notFoundFilepath), 187 }; 188 } 189 return { status: 404, content: "Not Found", contentType: "text/plain" };
··· 1 import { getContentTypeForExtension, trimLeadingSlash } from "./helpers.js"; 2 import path from "node:path"; 3 4 // Helpers 5 ··· 8 return getContentTypeForExtension(extension); 9 } 10 11 class KnotClient { 12 constructor({ domain, ownerDid, repoName, branch }) { 13 this.domain = domain; ··· 42 ownerDid, 43 repoName, 44 branch, 45 + baseDir = "/", 46 + notFoundFilepath = null, 47 }) { 48 this.domain = domain; 49 this.ownerDid = ownerDid; 50 this.repoName = repoName; 51 this.branch = branch; 52 + this.baseDir = baseDir; 53 + this.notFoundFilepath = notFoundFilepath; 54 this.client = new KnotClient({ 55 domain: domain, 56 ownerDid: ownerDid, ··· 59 }); 60 } 61 62 async getFileContent(filename) { 63 let content = null; 64 const blob = await this.client.getBlob(filename); 65 if (blob.is_binary) { ··· 67 } else { 68 content = blob.contents; 69 } 70 return content; 71 } 72 73 async getPage(route) { 74 let filePath = route; 75 const extension = path.extname(filePath); 76 if (extension === "") { 77 filePath = path.join(filePath, "index.html"); 78 } 79 + const fullPath = path.join(this.baseDir, trimLeadingSlash(filePath)); 80 const content = await this.getFileContent(fullPath); 81 if (!content) { 82 return this.get404(); ··· 89 } 90 91 async get404() { 92 + if (this.notFoundFilepath) { 93 + const content = await this.getFileContent(this.notFoundFilepath); 94 return { 95 status: 404, 96 content, 97 + contentType: getContentTypeForFilename(this.notFoundFilepath), 98 }; 99 } 100 return { status: 404, content: "Not Found", contentType: "text/plain" };
+71 -24
src/server.js
··· 1 import PagesService from "./pages-service.js"; 2 import express from "express"; 3 - import dotenv from "dotenv"; 4 5 - dotenv.config(); 6 7 - const pagesService = new PagesService({ 8 - domain: process.env.KNOT_DOMAIN, 9 - ownerDid: process.env.OWNER_DID, 10 - repoName: process.env.REPO_NAME, 11 - branch: process.env.BRANCH, 12 - verbose: process.env.NODE_ENV === "development", 13 - }); 14 15 - // preload to make sure there are no problems with the config 16 - await pagesService.loadConfig(); 17 18 - const app = express(); 19 20 - app.get("/{*any}", async (req, res) => { 21 - const route = req.path; 22 - const { status, content, contentType } = await pagesService.getPage(route); 23 - res.status(status).set("Content-Type", contentType).send(content); 24 - }); 25 26 - function main() { 27 - const server = app.listen(3000, () => { 28 - console.log("Server is running on port 3000"); 29 - }); 30 31 - server.on("error", (error) => { 32 - console.error("Server error:", error); 33 - }); 34 } 35 36 main();
··· 1 import PagesService from "./pages-service.js"; 2 import express from "express"; 3 + import fs from "fs"; 4 + import yargs from "yargs"; 5 6 + class Config { 7 + constructor({ site, sites, subdomainOffset }) { 8 + this.site = site; 9 + this.sites = sites || []; 10 + this.subdomainOffset = subdomainOffset; 11 + } 12 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 + this.app = express(); 23 24 + if (config.subdomainOffset) { 25 + this.app.set("subdomain offset", config.subdomainOffset); 26 + } 27 + 28 + this.app.get("/{*any}", async (req, res) => { 29 + const subdomain = req.subdomains.at(-1); 30 + // Single site mode 31 + if (!subdomain) { 32 + if (this.config.site) { 33 + return this.handleSiteRequest(req, res, this.config.site); 34 + } else { 35 + return res.status(200).send("Tangled pages is running!"); 36 + } 37 + } 38 + // Multi site mode 39 + const matchingSite = this.config.sites.find( 40 + (site) => site.subdomain === subdomain 41 + ); 42 + if (matchingSite) { 43 + await this.handleSiteRequest(req, res, matchingSite); 44 + } else { 45 + console.log("No matching site found for subdomain", subdomain); 46 + return res.status(404).send("Not Found"); 47 + } 48 + }); 49 + } 50 51 + async handleSiteRequest(req, res, site) { 52 + const route = req.path; 53 + const pagesService = new PagesService({ 54 + domain: site.knotDomain, 55 + ownerDid: site.ownerDid, 56 + repoName: site.repoName, 57 + branch: site.branch, 58 + baseDir: site.baseDir, 59 + notFoundFilepath: site.notFoundFilepath, 60 + }); 61 + const { status, content, contentType } = await pagesService.getPage(route); 62 + res.status(status).set("Content-Type", contentType).send(content); 63 + } 64 65 + async start() { 66 + this.app.listen(3000, () => { 67 + console.log("Server is running on port 3000"); 68 + }); 69 + this.app.on("error", (error) => { 70 + console.error("Server error:", error); 71 + }); 72 + } 73 + } 74 75 + async function main() { 76 + const args = yargs(process.argv.slice(2)).parse(); 77 + const configFilepath = args.config || "config.json"; 78 + const config = Config.fromFile(configFilepath); 79 + const server = new Server(config); 80 + await server.start(); 81 } 82 83 main();
+38 -23
src/worker.js
··· 1 import PagesService from "./pages-service.js"; 2 3 - let pagesService = null; 4 - 5 - // idk how long cloudflare keeps this around. 6 - // it would be better to save the config in a KV store 7 - // but this is good enough for now 8 - function getPagesService(env) { 9 - if (!pagesService) { 10 - pagesService = new PagesService({ 11 - domain: env.KNOT_DOMAIN, 12 - ownerDid: env.OWNER_DID, 13 - repoName: env.REPO_NAME, 14 - branch: env.BRANCH, 15 - verbose: env.NODE_ENV === "development", 16 - }); 17 - } 18 - return pagesService; 19 } 20 21 export default { 22 async fetch(request, env, ctx) { 23 - const route = new URL(request.url).pathname; 24 - const pagesService = getPagesService(env); 25 - const { status, content, contentType } = await pagesService.getPage(route); 26 - return new Response(content, { 27 - status, 28 - headers: { "Content-Type": contentType }, 29 - }); 30 }, 31 };
··· 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 host = url.host; 7 + const route = url.pathname; 8 + const pagesService = new PagesService({ 9 + domain: site.knotDomain, 10 + ownerDid: site.ownerDid, 11 + repoName: site.repoName, 12 + branch: site.branch, 13 + baseDir: site.baseDir, 14 + notFoundFilepath: site.notFoundFilepath, 15 + }); 16 + const { status, content, contentType } = await pagesService.getPage(route); 17 + return new Response(content, { 18 + status, 19 + headers: { "Content-Type": contentType }, 20 + }); 21 } 22 23 export default { 24 async fetch(request, env, ctx) { 25 + const url = new URL(request.url); 26 + const host = url.host; 27 + const subdomainOffset = config.subdomainOffset ?? 2; 28 + const subdomain = host.split(".").at((subdomainOffset + 1) * -1); 29 + // Single site mode 30 + if (!subdomain) { 31 + if (config.site) { 32 + return handleSiteRequest(request, config.site); 33 + } else { 34 + return new Response("Tangled pages is running!", { status: 200 }); 35 + } 36 + } 37 + // Multi site mode 38 + const matchingSite = config.sites.find( 39 + (site) => site.subdomain === subdomain 40 + ); 41 + if (matchingSite) { 42 + return handleSiteRequest(request, matchingSite); 43 + } 44 + return new Response("Not Found", { status: 404 }); 45 }, 46 };