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