cloudflare worker that uses records from atpr.to as a personal go links server
at main 147 lines 3.9 kB view raw
1export interface Env { 2 LINK_CACHE: DurableObjectNamespace; 3 DID: string; 4 COLLECTION: string; 5 PDS: string; 6 SITE_NAME: string; 7} 8 9export class LinkCache implements DurableObject { 10 private state: DurableObjectState; 11 private env: Env; 12 private syncing: Promise<void> | null = null; 13 14 constructor(state: DurableObjectState, env: Env) { 15 this.state = state; 16 this.env = env; 17 } 18 19 async fetch(request: Request): Promise<Response> { 20 const url = new URL(request.url); 21 const slug = url.pathname.slice(1); // strip leading / 22 23 if (!slug) { 24 return new Response(htmlPage(this.env.SITE_NAME, `My golinks server. Create links at <a href="https://atpr.to">atpr.to</a>.`), { 25 headers: { "content-type": "text/html;charset=utf-8" }, 26 }); 27 } 28 29 // Try cache first 30 const cached = await this.state.storage.get<string>(slug); 31 if (cached) { 32 // Refresh cache in background 33 this.triggerSync(); 34 return Response.redirect(cached, 302); 35 } 36 37 // Cache miss — sync and try again 38 await this.sync(); 39 const fresh = await this.state.storage.get<string>(slug); 40 if (fresh) { 41 return Response.redirect(fresh, 302); 42 } 43 44 return new Response(htmlPage("Not Found", `No golink found for <code>/${slug}</code>`), { 45 status: 404, 46 headers: { "content-type": "text/html;charset=utf-8" }, 47 }); 48 } 49 50 private triggerSync() { 51 if (!this.syncing) { 52 this.syncing = this.sync().finally(() => { 53 this.syncing = null; 54 }); 55 } 56 } 57 58 private async sync(): Promise<void> { 59 const records = await fetchAllRecords(this.env); 60 const fresh = new Map<string, string>(); 61 62 for (const record of records) { 63 const rkey = record.uri.split("/").pop()!; 64 const dest = record.value?.url; 65 if (rkey && dest) { 66 fresh.set(rkey, dest); 67 } 68 } 69 70 // Get all existing keys and delete stale ones 71 const existing = await this.state.storage.list(); 72 const toDelete: string[] = []; 73 for (const key of existing.keys()) { 74 if (!fresh.has(key)) { 75 toDelete.push(key); 76 } 77 } 78 if (toDelete.length > 0) { 79 await this.state.storage.delete(toDelete); 80 } 81 82 // Write all fresh records 83 if (fresh.size > 0) { 84 await this.state.storage.put(Object.fromEntries(fresh)); 85 } 86 } 87} 88 89interface ATRecord { 90 uri: string; 91 cid: string; 92 value: { url: string; $type: string; updatedAt?: string }; 93} 94 95async function fetchAllRecords(env: Env): Promise<ATRecord[]> { 96 const all: ATRecord[] = []; 97 let cursor: string | undefined; 98 99 do { 100 const params = new URLSearchParams({ 101 repo: env.DID, 102 collection: env.COLLECTION, 103 limit: "100", 104 }); 105 if (cursor) params.set("cursor", cursor); 106 107 const res = await fetch(`${env.PDS}/xrpc/com.atproto.repo.listRecords?${params}`); 108 if (!res.ok) { 109 throw new Error(`listRecords failed: ${res.status}`); 110 } 111 112 const data = (await res.json()) as { records: ATRecord[]; cursor?: string }; 113 all.push(...data.records); 114 cursor = data.cursor; 115 } while (cursor); 116 117 return all; 118} 119 120function htmlPage(title: string, body: string): string { 121 return `<!DOCTYPE html> 122<html> 123<head> 124 <meta charset="utf-8"> 125 <meta name="viewport" content="width=device-width, initial-scale=1"> 126 <title>${title}</title> 127 <style> 128 body { font-family: system-ui, sans-serif; max-width: 480px; margin: 80px auto; padding: 0 1rem; color: #333; } 129 h1 { font-size: 1.5rem; } 130 code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; } 131 a { color: #0066cc; } 132 </style> 133</head> 134<body> 135 <h1>${title}</h1> 136 <p>${body}</p> 137</body> 138</html>`; 139} 140 141export default { 142 async fetch(request: Request, env: Env): Promise<Response> { 143 const id = env.LINK_CACHE.idFromName("singleton"); 144 const stub = env.LINK_CACHE.get(id); 145 return stub.fetch(request); 146 }, 147};