goes to a random website hosted on wisp.place
at main 277 lines 8.6 kB view raw
1const WISP_API = Deno.env.get("WISP_API_URL") ?? "https://wisp.place"; 2const HYDRANT_BIN = Deno.env.get("HYDRANT_BIN") ?? "hydrant"; 3const PORT = parseInt(Deno.env.get("PORT") ?? "8080"); 4const KV_PATH = Deno.env.get("KV_PATH") ?? "random-wisp-place.kv"; 5const CURSOR = Deno.env.get("CURSOR"); 6 7const getFreePort = () => { 8 const listener = Deno.listen({ port: 0 }); 9 const port = (listener.addr as Deno.NetAddr).port; 10 listener.close(); 11 return port; 12}; 13 14const HYDRANT_PORT = getFreePort(); 15const HYDRANT_URL = `http://localhost:${HYDRANT_PORT}`; 16 17const FS_COLLECTION = "place.wisp.fs"; 18const DOMAIN_COLLECTION = "place.wisp.domain"; 19 20type SiteValue = { 21 fallbackUrl: string; 22 domainUrl: string | null; 23}; 24 25// secondary index: domain -> site key components 26type DomainIndexValue = { 27 did: string; 28 siteName: string; 29}; 30 31type HydrantRecord = { 32 readonly type: "record"; 33 readonly id: number; 34 readonly record: { 35 readonly did: string; 36 readonly collection: string; 37 readonly rkey: string; 38 readonly action: "create" | "update" | "delete"; 39 }; 40}; 41 42type HydrantEvent = HydrantRecord | { readonly type: "identity" | "account" }; 43 44type DomainRegistered = { 45 readonly registered: true; 46 readonly type: "wisp" | "custom"; 47 readonly domain: string; 48 readonly did: string; 49 readonly rkey: string | null; 50}; 51 52type DomainStatus = DomainRegistered | { readonly registered: false }; 53 54const siteKey = (did: string, siteName: string) => ["sites", did, siteName] as const; 55const domainKey = (domain: string) => ["domain_idx", domain] as const; 56const cursorKey = () => ["cursor"] as const; 57 58const fallbackUrl = (did: string, siteName: string): string => 59 `https://sites.wisp.place/${did}/${siteName}`; 60const resolveUrl = (site: SiteValue): string => 61 site.domainUrl ?? site.fallbackUrl; 62 63const kv = await Deno.openKv(KV_PATH); 64 65if (CURSOR) await kv.set(cursorKey(), parseInt(CURSOR)); 66 67const allSites = async (): Promise<SiteValue[]> => { 68 const entries: SiteValue[] = []; 69 for await (const entry of kv.list<SiteValue>({ prefix: ["sites"] })) { 70 entries.push(entry.value); 71 } 72 return entries; 73}; 74 75const queryDomainRegistered = async (domain: string): Promise<DomainStatus | null> => { 76 const url = new URL(`${WISP_API}/api/domain/registered`); 77 url.searchParams.set("domain", domain); 78 try { 79 const res = await fetch(url, { signal: AbortSignal.timeout(5_000) }); 80 return res.ok ? await res.json() as DomainStatus : null; 81 } catch { 82 return null; 83 } 84}; 85 86const handleFsEvent = async ( 87 did: string, 88 rkey: string, 89 action: "create" | "update" | "delete", 90): Promise<void> => { 91 const key = siteKey(did, rkey); 92 93 if (action === "delete") { 94 await kv.delete(key); 95 console.log(`[-] fs ${did}:${rkey}`); 96 return; 97 } 98 99 // preserve existing domainUrl on upsert 100 const existing = await kv.get<SiteValue>(key); 101 await kv.set(key, { 102 fallbackUrl: fallbackUrl(did, rkey), 103 domainUrl: existing.value?.domainUrl ?? null, 104 }); 105 console.log(`[+] fs ${action} ${did}:${rkey}`); 106}; 107 108const handleDomainEvent = async ( 109 _did: string, 110 rkey: string, 111 action: "create" | "update" | "delete", 112): Promise<void> => { 113 // rkey is the subdomain label e.g. "alice" -> alice.wisp.place 114 const domain = `${rkey}.wisp.place`; 115 const dKey = domainKey(domain); 116 117 if (action === "delete") { 118 const idx = await kv.get<DomainIndexValue>(dKey); 119 if (idx.value) { 120 const sKey = siteKey(idx.value.did, idx.value.siteName); 121 const site = await kv.get<SiteValue>(sKey); 122 if (site.value) { 123 await kv.set(sKey, { ...site.value, domainUrl: null }); 124 } 125 } 126 await kv.delete(dKey); 127 console.log(`[-] domain ${domain} unlinked`); 128 return; 129 } 130 131 const status = await queryDomainRegistered(domain); 132 if (!status?.registered || !status.rkey) { 133 console.warn(`[!] domain ${domain}: not registered, no site mapped, or api error`); 134 return; 135 } 136 137 const domainUrl = `https://${status.domain}/`; 138 const sKey = siteKey(status.did, status.rkey); 139 140 // update or pre-create the site row with the resolved domainUrl 141 const existing = await kv.get<SiteValue>(sKey); 142 await kv.atomic() 143 .set(sKey, { 144 fallbackUrl: existing.value?.fallbackUrl ?? fallbackUrl(status.did, status.rkey), 145 domainUrl, 146 }) 147 .set(dKey, { did: status.did, siteName: status.rkey } satisfies DomainIndexValue) 148 .commit(); 149 150 console.log(`[+] domain ${domain} -> ${status.did}:${status.rkey} (${status.type})`); 151}; 152 153const handleEvent = async (raw: string): Promise<void> => { 154 let event: HydrantEvent; 155 try { event = JSON.parse(raw) as HydrantEvent; } 156 catch { return; } 157 if (event.type !== "record") return; 158 159 const { did, collection, rkey, action } = event.record; 160 await kv.set(cursorKey(), event.id); 161 162 if (collection === FS_COLLECTION) { 163 await handleFsEvent(did, rkey, action); 164 } else if (collection === DOMAIN_COLLECTION) { 165 await handleDomainEvent(did, rkey, action); 166 } 167}; 168 169const connectToHydrant = async (cursor?: number): Promise<void> => { 170 const wsUrl = new URL(`${HYDRANT_URL.replace(/^http/, "ws")}/stream`); 171 if (cursor !== undefined) wsUrl.searchParams.set("cursor", String(cursor)); 172 173 console.log(`[?] connecting to hydrant: ${wsUrl}`); 174 const ws = new WebSocket(wsUrl.toString()); 175 176 ws.onopen = () => console.log("[?] hydrant stream connected"); 177 ws.onmessage = ({ data }) => { handleEvent(String(data)).catch(console.error); }; 178 ws.onerror = (e) => console.error("[!] ws error:", e); 179 ws.onclose = async () => { 180 const saved = (await kv.get<number>(cursorKey())).value ?? undefined; 181 console.log(`[!] ws closed (cursor=${saved ?? "none"}), reconnecting in 5s...`); 182 setTimeout(() => connectToHydrant(saved), 5_000); 183 }; 184}; 185 186const isReachable = async (url: string): Promise<boolean> => { 187 try { 188 const res = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(3_000) }); 189 return res.status !== 404; 190 } catch { 191 return false; 192 } 193}; 194 195const PROBE_BATCH = 10; 196const pickRandomReachable = async (sites: SiteValue[]): Promise<SiteValue | null> => { 197 const shuffled = [...sites].sort(() => Math.random() - 0.5); 198 for (let i = 0; i < shuffled.length; i += PROBE_BATCH) { 199 const batch = shuffled.slice(i, i + PROBE_BATCH); 200 const results = await Promise.all( 201 batch.map(async (site) => ({ site, ok: await isReachable(resolveUrl(site)) })) 202 ); 203 const found = results.find((r) => r.ok); 204 if (found) return found.site; 205 } 206 return null; 207}; 208 209const corsHeaders = { 210 headers: { 211 "Access-Control-Allow-Origin": "*", 212 "Access-Control-Allow-Methods": "GET", 213 } 214}; 215Deno.serve({ port: PORT }, async (req) => { 216 if (req.method === "OPTIONS") { 217 return new Response(null, { status: 204, ...corsHeaders }); 218 } 219 220 const { pathname } = new URL(req.url); 221 222 if (pathname === "/health") { 223 const sites = await allSites(); 224 const data = { 225 total: sites.length, 226 withDomain: sites.filter((s) => s.domainUrl).length, 227 }; 228 return Response.json(data, corsHeaders); 229 } 230 231 const site = await pickRandomReachable(await allSites()); 232 return site 233 ? Response.json(site, corsHeaders) 234 : new Response( 235 "no sites discovered yet, try again later", 236 { status: 503, ...corsHeaders }, 237 ); 238}); 239console.log(`[?] listening on :${PORT}`); 240 241console.log(`[?] starting hydrant on :${HYDRANT_PORT}...`); 242try { 243 const conf = (name: string, value: string) => Deno.env.set(`HYDRANT_${name}`, value); 244 conf("API_PORT", `${HYDRANT_PORT}`); 245 conf("ENABLE_CRAWLER", "true"); 246 conf("FILTER_SIGNALS", [FS_COLLECTION]); 247 conf("FILTER_COLLECTIONS", [FS_COLLECTION, DOMAIN_COLLECTION].join(",")); 248 conf("PLC_URL", "https://plc.directory"); 249 conf("ENABLE_DEBUG", "true"); 250 251 const cmd = new Deno.Command(HYDRANT_BIN, { 252 stdout: "inherit", 253 stderr: "inherit", 254 }); 255 const child = cmd.spawn(); 256 257 const cleanup = () => { 258 console.log("[?] shutting down hydrant..."); 259 child.kill("SIGTERM"); 260 Deno.exit(); 261 }; 262 263 Deno.addSignalListener("SIGTERM", cleanup); 264 Deno.addSignalListener("SIGINT", cleanup); 265 266 child.status.then((status) => { 267 console.error(`[!] hydrant process exited with code ${status.code}`); 268 Deno.exit(1); 269 }); 270} catch (e) { 271 console.error(`[!] failed to start hydrant: ${e.message}`); 272 Deno.exit(2); 273} 274 275const savedCursor = (await kv.get<number>(cursorKey())).value ?? undefined; 276console.log(`[?] resuming from cursor ${savedCursor ?? "start (0)"}`); 277connectToHydrant(savedCursor ?? 0);