import { createServer } from "node:http"; import { Readable } from "node:stream"; import { parseArgs } from "node:util"; const { values } = parseArgs({ args: process.argv.slice(2), options: { port: { type: "string", default: "3000" }, did: { type: "string" }, rkey: { type: "string" }, }, }); const did = values.did, collection = "com.jakelazaroff.athost", rkey = values.rkey; if (!did) { console.error("--did is required."); process.exit(1); } if (!rkey) { console.error("--rkey is required."); process.exit(1); } const doc = await resolveDid(did); const pds = doc.service[0].serviceEndpoint; /** @type {Error | undefined} */ let error; let record = await getRecord(pds, did, collection, rkey); let files = untar(await getBlob(pds, did, record.value.assets.ref.$link)); let updating = false; async function updateRecord() { if (updating) return; try { updating = true; record = await getRecord(pds, did, collection, rkey); files = untar(await getBlob(pds, did, record.value.assets.ref.$link)); error = undefined; } catch (e) { error = e; } finally { updating = false; } } /** * * @param {number} status * @param {string} message */ function fail(res, status, message) { res.statusCode = status; res.end(message); } const server = createServer(async (req, res) => { process.stdout.write(`${req.method} ${req.url} `); const start = performance.now(); let err; try { if (req.method !== "GET") return fail(res, 405, "Method not supported"); queueMicrotask(updateRecord); if (error) return fail(res, 502, "Bad gateway"); let path = req.url.slice(1); let asset = files[req.url.slice(1)]; let status = 200; // if there's no matching file, try to treat it as a folder and use an index.html inside it if (!asset) { path = req.url.slice(1).split("/").filter(Boolean).concat("index.html").join("/"); asset = files[path]; } // if there's *still* no matching file, return a generic 404 if (!asset) return fail(res, 404, "Not found"); res.statusCode = status; res.setHeader("content-type", getMimeType(path)); res.setHeader("content-length", asset.size); const stream = Readable.fromWeb(asset.stream()); await new Promise((resolve, reject) => { stream.pipe(res); stream.on("error", reject); stream.on("end", resolve); res.on("error", reject); }); } finally { const ms = performance.now() - start; process.stdout.write(`${res.statusCode} - ${Math.round(ms)}ms\n`); if (err) console.error(err); } }); const port = Number.parseInt(values.port) || 3000; server.listen(port, () => { console.log(`Server running at http://localhost:${port}`); console.log(`Proxying to at://${did}/${collection}/${rkey}`); console.log(""); }); /** @param {string} did */ async function resolveDid(did) { let url; if (did.startsWith("did:web:")) url = `https://${did.slice(8)}/.well-known/did.json`; else if (did.startsWith("did:plc:")) url = `https://plc.directory/${did}`; else throw new Error(`Unsupported did: ${did}`); const res = await fetch(url); const doc = await res.json(); return doc; } /** * @param {string} pds * @param {string} did * @param {string} collection * @param {string} rkey */ async function getRecord(pds, did, collection, rkey) { const url = new URL(`${pds}/xrpc/com.atproto.repo.getRecord`); url.searchParams.set("repo", did); url.searchParams.set("collection", collection); url.searchParams.set("rkey", rkey); const response = await fetch(url); return await response.json(); } /** * @param {string} pds * @param {string} did * @param {string} collection * @param {string} rkey */ async function getBlob(pds, did, cid) { const url = new URL(`${pds}/xrpc/com.atproto.sync.getBlob`); url.searchParams.set("did", did); url.searchParams.set("cid", cid); const res = await fetch(url); return new Uint8Array(await res.arrayBuffer()); } /** @param {Uint8Array} data */ function untar(data) { /** @type {Record} */ const files = {}; let offset = 0; while (offset < data.length) { // check if we've hit the end (two empty 512-byte blocks) if (data[offset] === 0) break; // read header (512 bytes) const header = data.slice(offset, offset + 512); // type flag (156) const typeflag = String.fromCharCode(header[156]); // file size (124-135, octal string) const sizeBytes = header.slice(124, 136); const sizeStr = new TextDecoder().decode(sizeBytes).trim().replace(/\0/g, ""); const size = Number.parseInt(sizeStr, 8) || 0; offset += 512; const paddedSize = Math.ceil(size / 512) * 512; // kkip directories and other non-file entries if (typeflag === "5" || typeflag === "x" || typeflag === "g") { offset += paddedSize; continue; } // file name (first 100 bytes, null-terminated) const nameBytes = header.slice(0, 100); const nameEnd = nameBytes.indexOf(0); const name = new TextDecoder().decode(nameBytes.slice(0, nameEnd > 0 ? nameEnd : 100)); if (!name) { offset += paddedSize; continue; } // read content const content = data.slice(offset, offset + size); offset += paddedSize; files[name] = new File([content], name.split("/").pop() || name); } return files; } /** @param {string} filename */ function getMimeType(filename) { const ext = filename.split(".").pop()?.toLowerCase(); const mimeTypes = { txt: "text/plain", html: "text/html", css: "text/css", js: "text/javascript", json: "application/json", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml", pdf: "application/pdf", zip: "application/zip", xml: "application/xml", }; return mimeTypes[ext] || "application/octet-stream"; }