serve a static website from your pds
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}