serve a static website from your pds

Improve schema

Changed files
+92 -46
src
routes
~
sites
+44 -31
proxy.js
··· 27 27 const doc = await resolveDid(did); 28 28 const pds = doc.service[0].serviceEndpoint; 29 29 30 + let record = await getRecord(pds, did, collection, rkey); 31 + let updating = false; 32 + 33 + async function updateRecord() { 34 + if (updating) return; 35 + try { 36 + updating = true; 37 + record = await getRecord(pds, did, collection, rkey); 38 + } finally { 39 + updating = false; 40 + } 41 + } 42 + 43 + /** 44 + * 45 + * @param {number} status 46 + * @param {string} message 47 + */ 48 + function error(res, status, message) { 49 + res.statusCode = status; 50 + res.end(message); 51 + } 52 + 30 53 const server = createServer(async (req, res) => { 31 54 process.stdout.write(`${req.method} ${req.url} `); 32 - if (req.method !== "GET") { 33 - res.statusCode = 405; 34 - res.end("Method not supported"); 35 - return; 36 - } 55 + const start = performance.now(); 37 56 38 - // TODO: keep leading slash 39 - const path = req.url.slice(1); 57 + try { 58 + if (req.method !== "GET") return error(res, 405, "Method not supported"); 59 + queueMicrotask(updateRecord); 40 60 41 - const record = await getRecord(pds, did, collection, rkey); 61 + let asset = record.value.assets[req.url.slice(1)]; 62 + if (!asset) { 63 + const path = req.url.slice(1).split("/"), 64 + filename = path.pop(); 65 + if (!/^.+\..+$/.test(filename)) { 66 + asset = record.value.assets[path.concat("index.html").join("/")]; 67 + } 68 + } 42 69 43 - for (const asset of record.value.assets) { 44 - if (asset.path !== path) continue; 70 + if (!asset) return error(res, 404, "Not found"); 45 71 46 72 try { 47 - const blob = await getBlob(pds, did, asset.file.ref.$link); 73 + const blob = await getBlob(pds, did, asset.ref.$link); 48 74 49 - if (!blob.ok) { 50 - console.error(`Upstream error ${blob.status}: ${blob.statusText}`); 51 - throw new Error("Response not ok"); 52 - } 53 - 54 - if (!blob.body) { 55 - console.error(`Blob body missing`); 56 - throw new Error("Blob body missing"); 57 - } 75 + if (!blob.ok) throw new Error(`Upstream error ${blob.status}: ${blob.statusText}`); 76 + if (!blob.body) throw new Error("Blob body missing"); 58 77 59 78 const contentType = blob.headers.get("content-type") || asset.file.mimeType; 60 79 res.setHeader("content-type", contentType); ··· 65 84 if (done) break; 66 85 res.write(value); 67 86 } 68 - 69 87 res.end(); 70 - process.stdout.write(`${res.statusCode} \n`); 71 - return; 72 88 } catch (error) { 73 - res.statusCode = 502; 74 - res.end("Error streaming blob"); 75 - process.stdout.write(`${res.statusCode} \n`); 76 - return; 89 + console.error(error); 90 + return error(res, 502, "Bad gateway"); 77 91 } 92 + } finally { 93 + const ms = performance.now() - start; 94 + process.stdout.write(`${res.statusCode} - ${Math.round(ms)}ms\n`); 78 95 } 79 - 80 - res.statusCode = 404; 81 - res.end("Not Found"); 82 - process.stdout.write(`${res.statusCode} \n`); 83 96 }); 84 97 85 98 const port = Number.parseInt(values.port) || 3000;
+47 -14
src/routes/~/sites/[name]/+page.svelte
··· 24 24 goto("/~/"); 25 25 } 26 26 27 + interface Bundle { 28 + description?: string; 29 + assets: Record< 30 + string, 31 + { $type: "blob"; ref: { $link: string }; mimeType: string; size: number } 32 + >; 33 + createdAt: string; 34 + } 35 + 27 36 async function deployBundle(e: SubmitEvent & { currentTarget: HTMLFormElement }) { 28 37 e.preventDefault(); 29 38 const form = e.currentTarget; ··· 36 45 if (typeof description !== "string" || !description) description = "Uploaded on website"; 37 46 38 47 const files = formdata.getAll("files"); 39 - const assets: { path: string; file: any }[] = []; 48 + const bundle: Bundle = { description, assets: {}, createdAt: new Date().toISOString() }; 40 49 for (const file of files) { 41 50 if (!(file instanceof File)) continue; 42 51 43 52 const { data } = await rpc.post("com.atproto.repo.uploadBlob", { input: file }); 44 53 if (isXRPCErrorPayload(data)) throw new Error("couldn't upload file"); 45 54 46 - assets.push({ 47 - path: file.name, 48 - file: { $type: "blob", ref: data.blob.ref, mimeType: file.type, size: file.size }, 49 - }); 55 + const filepath = file.webkitRelativePath?.replace(/^.+\//, "") ?? file.name; 56 + bundle.assets[filepath] = { 57 + $type: "blob", 58 + ref: data.blob.ref, 59 + mimeType: file.type, 60 + size: file.size, 61 + }; 50 62 } 51 63 52 - const record = { description, assets, createdAt: new Date().toISOString() }; 53 64 await rpc.post("com.atproto.repo.putRecord", { 54 - input: { repo: data.did, collection: "com.jakelazaroff.test", rkey, record }, 65 + input: { 66 + repo: data.did, 67 + collection: "com.jakelazaroff.test", 68 + rkey, 69 + record: bundle as any, 70 + }, 55 71 }); 56 72 if (isXRPCErrorPayload(data)) throw new Error("couldn't deploy"); 57 73 58 74 invalidate(`rkey:${rkey}`); 75 + form.reset(); 59 76 } 60 77 </script> 61 78 ··· 69 86 <time>{data.record.value.createdAt}</time> 70 87 </summary> 71 88 <ul> 72 - {#each data.record.value.assets as asset} 89 + {#each Object.entries(data.record.value.assets) as [path, file]} 73 90 <li> 74 - <span>{asset.path}</span> 91 + <span>{path}</span> 75 92 <a 76 93 target="_blank" 77 - href="{data.pds}xrpc/com.atproto.sync.getBlob?did={data.did}&cid={asset.file.ref.$link}" 78 - >open</a 94 + href="{data.pds}xrpc/com.atproto.sync.getBlob?did={data.did}&cid={file.ref.$link}">open</a 79 95 > 80 96 </li> 81 97 {/each} 82 98 </ul> 83 99 </details> 84 100 101 + <h3>upload</h3> 85 102 <form onsubmit={deployBundle}> 86 103 <input type="hidden" name="rkey" value={params.name} /> 87 - <input name="description" /> 88 - <input type="file" name="files" /> 104 + <label> 105 + <span>description</span> 106 + <input name="description" /> 107 + </label> 108 + <input type="file" name="files" webkitdirectory /> 89 109 <button>upload</button> 90 110 </form> 91 111 112 + <h3>settings</h3> 113 + <form> 114 + <label> 115 + <span>not found</span> 116 + <input name="notfound" /> 117 + </label> 118 + <button>save</button> 119 + </form> 120 + 121 + <h3>danger zone</h3> 92 122 <form onsubmit={deleteBundle}> 93 - <input name="rkey" pattern={params.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} /> 123 + <label> 124 + <span>name</span> 125 + <input name="rkey" pattern={params.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} /> 126 + </label> 94 127 <button>delete</button> 95 128 </form>
+1 -1
src/routes/~/sites/[name]/+page.ts
··· 1 1 import type {} from "@atcute/atproto"; 2 2 import { isXRPCErrorPayload } from "@atcute/client"; 3 + import { redirect } from "@sveltejs/kit"; 3 4 4 5 import { client, configure } from "~/lib/oauth"; 5 6 6 7 import type { PageLoad } from "./$types"; 7 - import { redirect } from "@sveltejs/kit"; 8 8 9 9 configure(); 10 10