serve a static website from your pds
at main 5.7 kB view raw
1import { createServer } from "node:http"; 2import { Readable } from "node:stream"; 3import { parseArgs } from "node:util"; 4 5const { values } = parseArgs({ 6 args: process.argv.slice(2), 7 options: { 8 port: { type: "string", default: "3000" }, 9 did: { type: "string" }, 10 rkey: { type: "string" }, 11 }, 12}); 13 14const did = values.did, 15 collection = "com.jakelazaroff.athost", 16 rkey = values.rkey; 17 18if (!did) { 19 console.error("--did is required."); 20 process.exit(1); 21} 22 23if (!rkey) { 24 console.error("--rkey is required."); 25 process.exit(1); 26} 27 28const doc = await resolveDid(did); 29const pds = doc.service[0].serviceEndpoint; 30 31/** @type {Error | undefined} */ 32let error; 33 34let record = await getRecord(pds, did, collection, rkey); 35let files = untar(await getBlob(pds, did, record.value.assets.ref.$link)); 36let updating = false; 37 38async function updateRecord() { 39 if (updating) return; 40 try { 41 updating = true; 42 record = await getRecord(pds, did, collection, rkey); 43 files = untar(await getBlob(pds, did, record.value.assets.ref.$link)); 44 error = undefined; 45 } catch (e) { 46 error = e; 47 } finally { 48 updating = false; 49 } 50} 51 52/** 53 * 54 * @param {number} status 55 * @param {string} message 56 */ 57function fail(res, status, message) { 58 res.statusCode = status; 59 res.end(message); 60} 61 62const server = createServer(async (req, res) => { 63 process.stdout.write(`${req.method} ${req.url} `); 64 const start = performance.now(); 65 66 let err; 67 try { 68 if (req.method !== "GET") return fail(res, 405, "Method not supported"); 69 queueMicrotask(updateRecord); 70 71 if (error) return fail(res, 502, "Bad gateway"); 72 73 let path = req.url.slice(1); 74 let asset = files[req.url.slice(1)]; 75 let status = 200; 76 77 // if there's no matching file, try to treat it as a folder and use an index.html inside it 78 if (!asset) { 79 path = req.url.slice(1).split("/").filter(Boolean).concat("index.html").join("/"); 80 asset = files[path]; 81 } 82 83 // if there's *still* no matching file, return a generic 404 84 if (!asset) return fail(res, 404, "Not found"); 85 86 res.statusCode = status; 87 res.setHeader("content-type", getMimeType(path)); 88 res.setHeader("content-length", asset.size); 89 90 const stream = Readable.fromWeb(asset.stream()); 91 await new Promise((resolve, reject) => { 92 stream.pipe(res); 93 stream.on("error", reject); 94 stream.on("end", resolve); 95 res.on("error", reject); 96 }); 97 } finally { 98 const ms = performance.now() - start; 99 process.stdout.write(`${res.statusCode} - ${Math.round(ms)}ms\n`); 100 if (err) console.error(err); 101 } 102}); 103 104const port = Number.parseInt(values.port) || 3000; 105server.listen(port, () => { 106 console.log(`Server running at http://localhost:${port}`); 107 console.log(`Proxying to at://${did}/${collection}/${rkey}`); 108 console.log(""); 109}); 110 111/** @param {string} did */ 112async function resolveDid(did) { 113 let url; 114 if (did.startsWith("did:web:")) url = `https://${did.slice(8)}/.well-known/did.json`; 115 else if (did.startsWith("did:plc:")) url = `https://plc.directory/${did}`; 116 else throw new Error(`Unsupported did: ${did}`); 117 118 const res = await fetch(url); 119 const doc = await res.json(); 120 return doc; 121} 122 123/** 124 * @param {string} pds 125 * @param {string} did 126 * @param {string} collection 127 * @param {string} rkey 128 */ 129async function getRecord(pds, did, collection, rkey) { 130 const url = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`); 131 url.searchParams.set("repo", did); 132 url.searchParams.set("collection", collection); 133 url.searchParams.set("rkey", rkey); 134 const response = await fetch(url); 135 return await response.json(); 136} 137 138/** 139 * @param {string} pds 140 * @param {string} did 141 * @param {string} collection 142 * @param {string} rkey 143 */ 144async function getBlob(pds, did, cid) { 145 const url = new URL(`${pds}/xrpc/com.atproto.sync.getBlob`); 146 url.searchParams.set("did", did); 147 url.searchParams.set("cid", cid); 148 149 const res = await fetch(url); 150 return new Uint8Array(await res.arrayBuffer()); 151} 152 153/** @param {Uint8Array} data */ 154function untar(data) { 155 /** @type {Record<string, File>} */ 156 const files = {}; 157 let offset = 0; 158 159 while (offset < data.length) { 160 // check if we've hit the end (two empty 512-byte blocks) 161 if (data[offset] === 0) break; 162 163 // read header (512 bytes) 164 const header = data.slice(offset, offset + 512); 165 166 // type flag (156) 167 const typeflag = String.fromCharCode(header[156]); 168 169 // file size (124-135, octal string) 170 const sizeBytes = header.slice(124, 136); 171 const sizeStr = new TextDecoder().decode(sizeBytes).trim().replace(/\0/g, ""); 172 const size = Number.parseInt(sizeStr, 8) || 0; 173 174 offset += 512; 175 const paddedSize = Math.ceil(size / 512) * 512; 176 177 // kkip directories and other non-file entries 178 if (typeflag === "5" || typeflag === "x" || typeflag === "g") { 179 offset += paddedSize; 180 continue; 181 } 182 183 // file name (first 100 bytes, null-terminated) 184 const nameBytes = header.slice(0, 100); 185 const nameEnd = nameBytes.indexOf(0); 186 const name = new TextDecoder().decode(nameBytes.slice(0, nameEnd > 0 ? nameEnd : 100)); 187 188 if (!name) { 189 offset += paddedSize; 190 continue; 191 } 192 193 // read content 194 const content = data.slice(offset, offset + size); 195 offset += paddedSize; 196 197 files[name] = new File([content], name.split("/").pop() || name); 198 } 199 200 return files; 201} 202 203/** @param {string} filename */ 204function getMimeType(filename) { 205 const ext = filename.split(".").pop()?.toLowerCase(); 206 const mimeTypes = { 207 txt: "text/plain", 208 html: "text/html", 209 css: "text/css", 210 js: "text/javascript", 211 json: "application/json", 212 png: "image/png", 213 jpg: "image/jpeg", 214 jpeg: "image/jpeg", 215 gif: "image/gif", 216 svg: "image/svg+xml", 217 pdf: "application/pdf", 218 zip: "application/zip", 219 xml: "application/xml", 220 }; 221 return mimeTypes[ext] || "application/octet-stream"; 222}