serve a static website from your pds

Add basic file upload and proxy

Changed files
+203 -16
src
+69
proxy.js
··· 1 + import { createServer } from "node:http"; 2 + 3 + import { Client, simpleFetchHandler } from "@atcute/client"; 4 + 5 + const did = "did:plc:vrrdgcidwpvn4omvn7uuufoo"; 6 + const pds = "https://bsky.social"; 7 + 8 + const handler = simpleFetchHandler({ service: pds }); 9 + const rpc = new Client({ handler }); 10 + 11 + const server = createServer(async (req, res) => { 12 + // TODO: keep leading slash 13 + const path = req.url.slice(1); 14 + 15 + const { data } = await rpc.get("com.atproto.repo.getRecord", { 16 + params: { 17 + repo: did, 18 + collection: "com.jakelazaroff.test", 19 + rkey: "jakelazaroff.com", 20 + }, 21 + }); 22 + 23 + for (const asset of data.value.assets) { 24 + if (asset.path !== path) continue; 25 + 26 + const url = `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${asset.file.ref.$link}`; 27 + const response = await fetch(url); 28 + 29 + if (!response.ok) { 30 + res.statusCode = response.status; 31 + res.end(`Failed to fetch blob: ${response.statusText}`); 32 + return; 33 + } 34 + 35 + const contentType = response.headers.get("content-type"); 36 + if (contentType) res.setHeader("content-type", contentType); 37 + 38 + if (response.body) { 39 + const reader = response.body.getReader(); 40 + 41 + try { 42 + while (true) { 43 + const { done, value } = await reader.read(); 44 + if (done) break; 45 + res.write(value); 46 + } 47 + res.end(); 48 + return; 49 + } catch (error) { 50 + res.statusCode = 500; 51 + res.end("Error streaming blob"); 52 + return; 53 + } 54 + } else { 55 + res.statusCode = 500; 56 + res.end("No response body"); 57 + return; 58 + } 59 + } 60 + 61 + res.statusCode = 404; 62 + res.end("Not Found"); 63 + }); 64 + 65 + const PORT = 3000; 66 + 67 + server.listen(PORT, () => { 68 + console.log(`Server running at http://localhost:${PORT}/`); 69 + });
+7 -1
src/lib/oauth.ts
··· 1 1 import { PUBLIC_HOST, PUBLIC_REDIRECT_HOST } from "$env/static/public"; 2 2 3 - import { configureOAuth } from "@atcute/oauth-browser-client"; 3 + import { Client } from "@atcute/client"; 4 + import { configureOAuth, OAuthUserAgent, type Session } from "@atcute/oauth-browser-client"; 4 5 5 6 export const CLIENT_ID = `${PUBLIC_HOST}/client-metadata.json`; 6 7 export const REDIRECT_URI = `${PUBLIC_REDIRECT_HOST || PUBLIC_HOST}/oauth/callback`; ··· 8 9 export function configure() { 9 10 configureOAuth({ metadata: { client_id: CLIENT_ID, redirect_uri: REDIRECT_URI } }); 10 11 } 12 + 13 + export function client(session: Session) { 14 + const handler = new OAuthUserAgent(session); 15 + return new Client({ handler }); 16 + }
+10 -2
src/routes/~/+layout.svelte
··· 17 17 } 18 18 </script> 19 19 20 - <header> 21 - <h1>hello, {data.name}</h1> 20 + <header class="header"> 21 + <h1><a href="/~/">hello, {data.name}</a></h1> 22 22 <button onclick={logOut}>log out</button> 23 23 </header> 24 24 25 25 <div class="body"> 26 26 {@render children?.()} 27 27 </div> 28 + 29 + <style> 30 + .header { 31 + display: flex; 32 + justify-content: space-between; 33 + align-items: center; 34 + } 35 + </style>
+19 -11
src/routes/~/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { invalidate } from "$app/navigation"; 3 + 2 4 import type {} from "@atcute/atproto"; 3 5 import { Client } from "@atcute/client"; 4 6 import { OAuthUserAgent } from "@atcute/oauth-browser-client"; ··· 10 12 11 13 async function createWebsite(e: SubmitEvent & { currentTarget: HTMLFormElement }) { 12 14 e.preventDefault(); 13 - const form = new FormData(e.currentTarget); 15 + const form = e.currentTarget; 16 + const formdata = new FormData(e.currentTarget); 14 17 15 - const name = form.get("name"); 18 + const name = formdata.get("name"); 16 19 if (typeof name !== "string") throw new Error("invalid name"); 17 20 18 - await rpc.post("com.atproto.repo.putRecord", { 19 - input: { 20 - repo: data.did, 21 - collection: "com.jakelazaroff.test", 22 - rkey: name, 23 - record: {}, 24 - }, 21 + const record = { 22 + assets: [], 23 + createdAt: new Date().toISOString(), 24 + }; 25 + 26 + await rpc.post("com.atproto.repo.createRecord", { 27 + input: { repo: data.did, collection: "com.jakelazaroff.test", rkey: name, record }, 25 28 }); 29 + await invalidate("collection:com.jakelazaroff.test"); 30 + form.reset(); 26 31 } 27 32 </script> 28 33 29 34 <form onsubmit={createWebsite}> 30 - <input type="text" name="name" /> 35 + <input type="text" name="name" minlength={1} maxlength={512} pattern="[A-Za-z0-9.\-]+" /> 31 36 <button>create</button> 32 37 </form> 33 38 34 39 <ul> 35 40 {#each data.records as record} 36 - <li>{record.uri}</li> 41 + {@const rkey = record.uri.split("/").at(-1) || ""} 42 + <li> 43 + <a href="/~/sites/{rkey}">{rkey}</a> 44 + </li> 37 45 {/each} 38 46 </ul>
+4 -2
src/routes/~/+page.ts
··· 1 1 import type {} from "@atcute/atproto"; 2 2 import { Client, isXRPCErrorPayload } from "@atcute/client"; 3 - import { getSession, OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 + import { OAuthUserAgent } from "@atcute/oauth-browser-client"; 4 4 5 5 import { configure } from "~/lib/oauth"; 6 6 ··· 9 9 10 10 configure(); 11 11 12 - export const load: PageLoad = async ({ parent }) => { 12 + export const load: PageLoad = async ({ parent, depends }) => { 13 + depends("collection:com.jakelazaroff.test"); 14 + 13 15 try { 14 16 const { did, session } = await parent(); 15 17
+64
src/routes/~/sites/[name]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { invalidate } from "$app/navigation"; 3 + 4 + import type {} from "@atcute/atproto"; 5 + import { isXRPCErrorPayload } from "@atcute/client"; 6 + 7 + import { client } from "~/lib/oauth"; 8 + 9 + let { params, data } = $props(); 10 + 11 + const rpc = client(data.session); 12 + 13 + async function deleteWebsite(rkey: string) { 14 + await rpc.post("com.atproto.repo.deleteRecord", { 15 + input: { repo: data.did, collection: "com.jakelazaroff.test", rkey }, 16 + }); 17 + } 18 + 19 + async function deployBundle(e: SubmitEvent & { currentTarget: HTMLFormElement }) { 20 + e.preventDefault(); 21 + const form = e.currentTarget; 22 + const formdata = new FormData(form); 23 + 24 + const name = formdata.get("name"); 25 + if (typeof name !== "string") throw new Error("invalid name"); 26 + 27 + const files = formdata.getAll("files"); 28 + const assets: { path: string; file: any }[] = []; 29 + for (const file of files) { 30 + if (!(file instanceof File)) continue; 31 + 32 + const { data } = await rpc.post("com.atproto.repo.uploadBlob", { input: file }); 33 + if (isXRPCErrorPayload(data)) throw new Error("couldn't upload file"); 34 + 35 + assets.push({ 36 + path: file.name, 37 + file: { $type: "blob", ref: data.blob.ref, mimeType: file.type, size: file.size }, 38 + }); 39 + } 40 + 41 + const record = { assets, createdAt: new Date().toISOString() }; 42 + await rpc.post("com.atproto.repo.putRecord", { 43 + input: { repo: data.did, collection: "com.jakelazaroff.test", rkey: name, record }, 44 + }); 45 + if (isXRPCErrorPayload(data)) throw new Error("couldn't deploy"); 46 + } 47 + </script> 48 + 49 + <header class="header"> 50 + <h2>{params.name}</h2> 51 + <button onclick={() => deleteWebsite(params.name)}>delete</button> 52 + </header> 53 + 54 + <ul> 55 + {#each data.record.value.assets as asset} 56 + <li>{asset.path}</li> 57 + {/each} 58 + </ul> 59 + 60 + <form onsubmit={deployBundle}> 61 + <input type="hidden" name="name" value={params.name} /> 62 + <input type="file" name="files" /> 63 + <button>upload</button> 64 + </form>
+30
src/routes/~/sites/[name]/+page.ts
··· 1 + import type {} from "@atcute/atproto"; 2 + import { isXRPCErrorPayload } from "@atcute/client"; 3 + 4 + import { client, configure } from "~/lib/oauth"; 5 + 6 + import type { PageLoad } from "./$types"; 7 + import { redirect } from "@sveltejs/kit"; 8 + 9 + configure(); 10 + 11 + export const load: PageLoad = async ({ parent, params, depends }) => { 12 + depends("collection:com.jakelazaroff.test"); 13 + 14 + try { 15 + const { did, session } = await parent(); 16 + 17 + const rpc = client(session); 18 + 19 + const { data } = await rpc.get("com.atproto.repo.getRecord", { 20 + params: { repo: did, collection: "com.jakelazaroff.test", rkey: params.name }, 21 + }); 22 + 23 + if (isXRPCErrorPayload(data)) throw new Error("couldn't load records"); 24 + 25 + return { record: data }; 26 + } catch (e) { 27 + console.error(e); 28 + redirect(303, "/"); 29 + } 30 + };