goes to a random website hosted on wisp.place
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);