cloudflare worker that uses records from atpr.to as a personal go links server
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};