-6
.example.env
-6
.example.env
+11
config.worker..example.json
+11
config.worker..example.json
+157
-21
package-lock.json
+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
+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
+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
+4
wrangler.toml
+8
-12
config.worker.example.json
+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
+2
-1
config.example.json
+6
-2
src/worker.js
+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
+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
+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
+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
+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
}