serve a static website from your pds

Upload tarball

+114 -97
+102 -35
proxy.js
··· 1 1 import { createServer } from "node:http"; 2 + import { Readable } from "node:stream"; 2 3 import { parseArgs } from "node:util"; 3 4 4 5 const { values } = parseArgs({ ··· 27 28 const doc = await resolveDid(did); 28 29 const pds = doc.service[0].serviceEndpoint; 29 30 31 + /** @type {Error | undefined} */ 32 + let error; 33 + 30 34 let record = await getRecord(pds, did, collection, rkey); 35 + let files = untar(await getBlob(pds, did, record.value.assets.ref.$link)); 31 36 let updating = false; 32 37 33 38 async function updateRecord() { ··· 35 40 try { 36 41 updating = true; 37 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; 38 47 } finally { 39 48 updating = false; 40 49 } ··· 45 54 * @param {number} status 46 55 * @param {string} message 47 56 */ 48 - function error(res, status, message) { 57 + function fail(res, status, message) { 49 58 res.statusCode = status; 50 59 res.end(message); 51 60 } ··· 56 65 57 66 let err; 58 67 try { 59 - if (req.method !== "GET") return error(res, 405, "Method not supported"); 68 + if (req.method !== "GET") return fail(res, 405, "Method not supported"); 60 69 queueMicrotask(updateRecord); 61 70 62 - let asset = record.value.assets[req.url.slice(1)]; 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)]; 63 75 let status = 200; 64 76 65 77 // if there's no matching file, try to treat it as a folder and use an index.html inside it 66 78 if (!asset) { 67 - const path = req.url.slice(1).split("/").filter(Boolean).concat("index.html").join("/"); 68 - asset = record.value.assets[path]; 69 - } 70 - 71 - // if there's still no matching file and a fallback is defined, try to use it 72 - if (!asset && record.value.fallback) { 73 - asset = record.value.assets[record.value.fallback.path]; 74 - if (record.value.fallback.status) status = record.value.fallback.status; 79 + path = req.url.slice(1).split("/").filter(Boolean).concat("index.html").join("/"); 80 + asset = files[path]; 75 81 } 76 82 77 83 // if there's *still* no matching file, return a generic 404 78 - if (!asset) return error(res, 404, "Not found"); 84 + if (!asset) return fail(res, 404, "Not found"); 79 85 80 - // fetch the file's blob 81 - try { 82 - const blob = await getBlob(pds, did, asset.ref.$link); 86 + res.statusCode = status; 87 + res.setHeader("content-type", getMimeType(path)); 88 + res.setHeader("content-length", asset.size); 83 89 84 - if (!blob.ok) throw new Error(`Upstream error ${blob.status}: ${blob.statusText}`); 85 - if (!blob.body) throw new Error("Blob body missing"); 86 - 87 - const contentType = blob.headers.get("content-type") || asset.file.mimeType; 88 - res.setHeader("content-type", contentType); 89 - res.statusCode = status; 90 - 91 - const reader = blob.body.getReader(); 92 - while (true) { 93 - const { done, value } = await reader.read(); 94 - if (done) break; 95 - res.write(value); 96 - } 97 - 98 - res.end(); 99 - } catch (e) { 100 - err = e; 101 - return error(res, 502, "Bad gateway"); 102 - } 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 + }); 103 97 } finally { 104 98 const ms = performance.now() - start; 105 99 process.stdout.write(`${res.statusCode} - ${Math.round(ms)}ms\n`); ··· 151 145 const url = new URL(`${pds}/xrpc/com.atproto.sync.getBlob`); 152 146 url.searchParams.set("did", did); 153 147 url.searchParams.set("cid", cid); 154 - return await fetch(url); 148 + 149 + const res = await fetch(url); 150 + return new Uint8Array(await res.arrayBuffer()); 151 + } 152 + 153 + /** @param {Uint8Array} data */ 154 + function 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 */ 204 + function 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"; 155 222 }
+6 -27
src/lib/atproto.ts
··· 11 11 description?: string; 12 12 13 13 /** A map of asset paths to blob references. */ 14 - assets: Record<string, { $type: "blob"; ref: { $link: string }; mimeType: string; size: number }>; 15 - 16 - fallback?: { 17 - path: string; 18 - status?: number; 19 - }; 14 + assets: { $type: "blob"; ref: { $link: string }; mimeType: string; size: number }; 20 15 } 21 16 22 17 export default class ATProto { ··· 68 63 return data as any as Omit<typeof data, "value"> & { value: Bundle }; 69 64 } 70 65 71 - async uploadFiles(files: File[]) { 72 - const assets: Bundle["assets"] = {}; 73 - let throttle = Promise.resolve(); 74 - for (const file of files) { 75 - if (!(file instanceof File)) continue; 76 - await throttle; 77 - 78 - const { data } = await this.#client.post("com.atproto.repo.uploadBlob", { input: file }); 79 - if (isXRPCErrorPayload(data)) throw new Error("couldn't upload file"); 80 - 81 - const filepath = file.webkitRelativePath?.replace(/^.+\//, "") ?? file.name; 82 - assets[filepath] = { 83 - $type: "blob", 84 - ref: data.blob.ref, 85 - mimeType: file.type, 86 - size: file.size, 87 - }; 88 - 89 - throttle = new Promise(r => setTimeout(r, 1000)); 90 - } 66 + async uploadFile(file: File) { 67 + const { data } = await this.#client.post("com.atproto.repo.uploadBlob", { input: file }); 68 + if (isXRPCErrorPayload(data)) throw new Error(`couldn't upload ${file.name}`); 91 69 92 - return assets; 70 + return data.blob; 93 71 } 94 72 95 73 async updateBundle(rkey: string, bundle: Bundle) { ··· 101 79 record: { ...(bundle as any), createdAt: new Date().toISOString() }, 102 80 }, 103 81 }); 82 + 104 83 if (isXRPCErrorPayload(data)) throw new Error("couldn't deploy"); 105 84 } 106 85
+2 -2
src/routes/~/+layout.svelte
··· 27 27 <header class="header"> 28 28 <h1><a href="/~/">athost</a></h1> 29 29 <button class="menubutton" popovertarget="menu"> 30 - <img class="avatar" src={data.profile.avatar} alt="" width={20} /> 31 - <span>@{data.profile.displayName}</span> 30 + {#if data.profile.avatar}<img class="avatar" src={data.profile.avatar} alt="" width={20} />{/if} 31 + <span>@{data.profile.handle}</span> 32 32 </button> 33 33 <div id="menu" class="menu" popover> 34 34 <ul class="themes">
+1
src/routes/~/+layout.ts
··· 15 15 const atp = client(session); 16 16 17 17 const profile = await atp.getProfile(did); 18 + console.log(profile); 18 19 19 20 return { session, pds: session.info.aud, did, profile }; 20 21 } catch (e) {
+3 -33
src/routes/~/sites/[name]/+page.svelte
··· 33 33 if (typeof description !== "string" || !description) description = defaultDescription; 34 34 35 35 const files = formdata.getAll("files").filter(entry => entry instanceof File); 36 - const assets = await atp.uploadFiles(files); 36 + const assets = await atp.uploadFile(files[0]); 37 37 38 38 await atp.updateBundle(rkey, { ...(data.record.value as any), description, assets }); 39 39 invalidate(`rkey:${rkey}`); ··· 62 62 invalidate(`rkey:${rkey}`); 63 63 form.reset(); 64 64 } 65 - 66 - $inspect(data.record.value.fallback?.status); 67 65 </script> 68 66 69 67 <div class="detail"> ··· 81 79 {getRelativeTime(new Date(data.record.value.createdAt))} 82 80 </time> 83 81 </summary> 84 - <ul class="deploy-files"> 82 + <!-- <ul class="deploy-files"> 85 83 {#each Object.entries(data.record.value.assets) as [path, file]} 86 84 <li> 87 85 <span>{path}</span> ··· 92 90 > 93 91 </li> 94 92 {/each} 95 - </ul> 93 + </ul> --> 96 94 </details> 97 95 98 96 <section class="section"> ··· 118 116 <Icon name="toggle" /> 119 117 <span>Settings</span> 120 118 </h3> 121 - <form onsubmit={updateBundle}> 122 - <fieldset> 123 - <legend>Fallback</legend> 124 - <label> 125 - <span>path</span> 126 - <input name="fallback_path" value={data.record.value.fallback?.path} /> 127 - </label> 128 - <label> 129 - <span>200</span> 130 - <input 131 - type="radio" 132 - name="fallback_status" 133 - value="200" 134 - group={data.record.value.fallback?.status} 135 - /> 136 - </label> 137 - <label> 138 - <span>404</span> 139 - <input 140 - type="radio" 141 - name="fallback_status" 142 - value="404" 143 - group={data.record.value.fallback?.status} 144 - /> 145 - </label> 146 - </fieldset> 147 - <button>save</button> 148 - </form> 149 119 </section> 150 120 151 121 <section class="section">