Static site hosting via tangled

Compare changes

Choose any two refs to compare.

+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,
+12
src/atproto.js
··· 10 10 return service.serviceEndpoint; 11 11 } 12 12 13 + export async function resolveHandle(handle) { 14 + const params = new URLSearchParams({ 15 + handle, 16 + }); 17 + const res = await fetch( 18 + "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?" + 19 + params.toString() 20 + ); 21 + const data = await res.json(); 22 + return data.did; 23 + } 24 + 13 25 async function resolveDid(did) { 14 26 if (did.startsWith("did:plc:")) { 15 27 const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`);
+32 -14
src/handler.js
··· 1 1 import { PagesService } from "./pages-service.js"; 2 2 import { KnotEventListener } from "./knot-event-listener.js"; 3 - import { listRecords } from "./atproto.js"; 3 + import { listRecords, resolveHandle } from "./atproto.js"; 4 4 5 5 async function getKnotDomain(did, repoName) { 6 6 const repos = await listRecords({ ··· 14 14 return repo.value.knot; 15 15 } 16 16 17 + function parseTangledUrl(tangledUrl) { 18 + // e.g. https://tangled.sh/@gracekind.net/tangled-pages-example 19 + const regex = /^https:\/\/tangled\.sh\/@(.+)\/(.+)$/; 20 + const match = tangledUrl.match(regex); 21 + if (!match) { 22 + throw new Error(`Invalid tangled URL: ${tangledUrl}`); 23 + } 24 + return { 25 + handle: match[1], 26 + repoName: match[2], 27 + }; 28 + } 29 + 17 30 async function getPagesServiceForSite(siteOptions, config) { 31 + // Fetch repoName and ownerDid if needed 32 + let ownerDid = siteOptions.ownerDid; 33 + let repoName = siteOptions.repoName; 34 + 35 + if (siteOptions.tangledUrl) { 36 + const { handle, repoName: parsedRepoName } = parseTangledUrl( 37 + siteOptions.tangledUrl 38 + ); 39 + console.log("Getting ownerDid for", handle); 40 + const did = await resolveHandle(handle); 41 + ownerDid = did; 42 + repoName = parsedRepoName; 43 + } 44 + // Fetch knot domain if needed 18 45 let knotDomain = siteOptions.knotDomain; 19 46 if (!knotDomain) { 20 - console.log( 21 - "Getting knot domain for", 22 - siteOptions.ownerDid + "/" + siteOptions.repoName 23 - ); 24 - knotDomain = await getKnotDomain( 25 - siteOptions.ownerDid, 26 - siteOptions.repoName 27 - ); 47 + console.log("Getting knot domain for", ownerDid + "/" + repoName); 48 + knotDomain = await getKnotDomain(ownerDid, repoName); 28 49 } 29 50 return new PagesService({ 30 51 knotDomain, 31 - ownerDid: siteOptions.ownerDid, 32 - repoName: siteOptions.repoName, 52 + ownerDid, 53 + repoName, 33 54 branch: siteOptions.branch, 34 55 baseDir: siteOptions.baseDir, 35 56 notFoundFilepath: siteOptions.notFoundFilepath, ··· 38 59 } 39 60 40 61 async function getPagesServiceMap(config) { 41 - if (config.site && config.sites) { 42 - throw new Error("Cannot use both site and sites in config"); 43 - } 44 62 const pagesServiceMap = {}; 45 63 if (config.site) { 46 64 pagesServiceMap[""] = await getPagesServiceForSite(config.site, config);
+13 -6
src/knot-client.js
··· 9 9 } 10 10 11 11 async getBlob(filename) { 12 - const url = `https://${this.domain}/${this.ownerDid}/${ 13 - this.repoName 14 - }/blob/${this.branch}/${trimLeadingSlash(filename)}`; 12 + const params = new URLSearchParams({ 13 + repo: `${this.ownerDid}/${this.repoName}`, 14 + path: trimLeadingSlash(filename), 15 + ref: this.branch, 16 + }); 17 + const url = `https://${this.domain}/xrpc/sh.tangled.repo.blob?${params}`; 15 18 console.log(`[KNOT CLIENT]: GET ${url}`); 16 19 const res = await fetch(url); 17 20 return await res.json(); 18 21 } 19 22 20 23 async getRaw(filename) { 21 - const url = `https://${this.domain}/${this.ownerDid}/${this.repoName}/raw/${ 22 - this.branch 23 - }/${trimLeadingSlash(filename)}`; 24 + const params = new URLSearchParams({ 25 + repo: `${this.ownerDid}/${this.repoName}`, 26 + path: trimLeadingSlash(filename), 27 + ref: this.branch, 28 + raw: "true", 29 + }); 30 + const url = `https://${this.domain}/xrpc/sh.tangled.repo.blob?${params}`; 24 31 console.log(`[KNOT CLIENT]: GET ${url}`); 25 32 const res = await fetch(url, { 26 33 responseType: "arraybuffer",
+2 -2
src/pages-service.js
··· 67 67 } 68 68 let content = null; 69 69 const blob = await this.client.getBlob(filename); 70 - if (blob.is_binary) { 70 + if (blob.isBinary) { 71 71 content = await this.client.getRaw(filename); 72 72 } else { 73 - content = blob.contents; 73 + content = blob.content; 74 74 } 75 75 if (this.fileCache && content) { 76 76 const contentSize = Buffer.isBuffer(content)