+14
-1
caddy/Caddyfile
+14
-1
caddy/Caddyfile
···
123
123
format console
124
124
}
125
125
126
-
@landing path / /css
126
+
rewrite / /pds
127
+
@landing path /pds /styles.css
127
128
reverse_proxy @landing landing:8000
129
+
130
+
# disable age assurance
131
+
handle /xrpc/app.bsky.ageassurance.getState {
132
+
header content-type "application/json"
133
+
header access-control-allow-headers "authorization,dpop,atproto-accept-labelers,atproto-proxy"
134
+
header access-control-allow-origin "*"
135
+
respond `{"state":{"lastInitiatedAt":"2025-07-14T14:22:43.912Z","status":"assured","access":"full"},"metadata":{"accountCreatedAt":"2022-11-17T00:35:16.391Z"}}` 200
136
+
}
128
137
129
138
reverse_proxy {$PI_ADDRESS:pi}:8000 {
130
139
transport http {
···
159
168
output stdout
160
169
format console
161
170
}
171
+
172
+
rewrite / /knot
173
+
@landing path /knot /styles.css
174
+
reverse_proxy @landing landing:8000
162
175
163
176
reverse_proxy {$PI_ADDRESS:pi}:5555
164
177
}
+11
compose.yaml
+11
compose.yaml
···
12
12
build: ./landing
13
13
environment:
14
14
- PORT=8000
15
+
- PDS=http://100.84.64.24:8000
16
+
- KNOT_HOST=http://100.84.64.24:5555
17
+
# tangled uses a plain domain name for knots in lexicons. imo this is bad but it is what it is rn
18
+
# allows me to have a diff HOST to the public one which may improve speeds ig?
19
+
- KNOT_NAME=knot.vielle.dev
15
20
restart: unless-stopped
16
21
17
22
caddy:
···
29
34
- caddy_config:/config
30
35
environment:
31
36
HOST: vielle.dev
37
+
DONG_HOST: dongs.zip
38
+
PDS_ADMIN_EMAIL: admin@vielle.dev
39
+
PI_ADDRESS: "100.84.64.24"
40
+
PI_PORT_PDS: 8000
41
+
PI_PORT_KNOT: 5555
42
+
PI_PORT_PIPER: 8010
32
43
depends_on:
33
44
- prs
34
45
- landing
+2
-2
landing/Dockerfile
+2
-2
landing/Dockerfile
···
3
3
4
4
COPY ./ /app
5
5
RUN deno install
6
-
RUN deno bundle --unstable-raw-imports --output bundle.js /app/index.ts
6
+
RUN deno bundle --unstable-raw-imports --output bundle.js /app/src/index.ts
7
7
8
-
CMD deno --allow-net --unstable-temporal bundle.js
8
+
CMD deno --allow-net --unstable-temporal --allow-env bundle.js
+3
landing/deno.json
+3
landing/deno.json
landing/heading.txt
landing/src/pds/pds-heading.txt
landing/heading.txt
landing/src/pds/pds-heading.txt
+18
-50
landing/index.ts
landing/src/pds/pds.ts
+18
-50
landing/index.ts
landing/src/pds/pds.ts
···
1
-
import css from "./styles.css" with { type: "text" };
2
-
import heading from "./heading.txt" with { type: "text" };
1
+
import heading from "./pds-heading.txt" with { type: "text" };
3
2
import {
4
3
didDocSchema,
5
4
getRecordSchema,
···
8
7
profileSelfSchema,
9
8
statusphereSchema,
10
9
tealFmStatusSchema,
11
-
} from "./schemas.ts";
10
+
} from "../schemas.ts";
11
+
import { stagger } from "../utils.ts";
12
12
13
-
const PORT = Number(Deno.env.get("PORT"));
14
13
const PDS = Deno.env.get("PDS") ?? "http://pi:8000";
14
+
const PLC_DIRECTORY = Deno.env.get("PLC_DIRECTORY") ?? "https://plc.directory";
15
15
16
16
async function renderUser(did: string): Promise<string> {
17
17
const handle = fetch(
18
18
did.startsWith("did:plc")
19
-
? "https://plc.directory/" + did
19
+
? `${PLC_DIRECTORY}/${did}`
20
20
: `https://${did.replace("did:web:", "")}/.well-known/did.json`
21
21
)
22
22
.then((res) => res.json())
···
25
25
(doc) =>
26
26
doc.alsoKnownAs
27
27
.filter((x) => x.startsWith("at://"))[0]
28
-
?.replace("at://", "") ?? "invalid.handle"
29
-
);
28
+
?.replace("at://", "") ?? "handle.invalid"
29
+
)
30
+
.catch(() => "handle.invalid");
30
31
31
-
const displayName = fetch(
32
+
const displayName: Promise<string | undefined> = fetch(
32
33
`${PDS}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self`
33
34
)
34
35
.then((res) => res.json())
35
36
.then((data) => getRecordSchema(profileSelfSchema).safeParse(data).data)
36
-
.then((data) => (data ? data.value.displayName : undefined));
37
+
.then((data) => (data ? data.value.displayName : undefined))
38
+
.catch(() => undefined);
37
39
38
-
const statusphere = fetch(
40
+
const statusphere: Promise<string | undefined> = fetch(
39
41
`${PDS}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=xyz.statusphere.status`
40
42
)
41
43
.then((res) => res.json())
42
44
.then((data) => listRecordsSchema(statusphereSchema).safeParse(data).data)
43
45
.then((data) =>
44
46
data && data.records.length > 0 ? data.records[0].value.status : undefined
45
-
);
47
+
)
48
+
.catch(() => undefined);
46
49
47
50
const nowPlaying = fetch(
48
51
`${PDS}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=fm.teal.alpha.actor.status&rkey=self`
···
59
62
? `${data.value.item.artists.length - 1} more artist${data.value.item.artists.length > 1 ? "s" : ""}`
60
63
: ""))
61
64
: undefined
62
-
);
65
+
)
66
+
.catch(() => undefined);
63
67
64
68
return (
65
69
"- " +
···
77
81
);
78
82
}
79
83
80
-
function landingPage(): Response {
84
+
export default function (): Response {
81
85
// get upstream pds status
82
86
const status = fetch(`${PDS}/xrpc/_health`).then(
83
87
async (res) => `${res.status} ${res.statusText} ${await res.text()}`
···
107
111
},
108
112
});
109
113
110
-
// buffer the output just to be easier to handle
111
-
const stagger = new TransformStream({
112
-
async transform(
113
-
chunk: string,
114
-
controller: TransformStreamDefaultController<string>
115
-
) {
116
-
for (const part of chunk) {
117
-
controller.enqueue(part);
118
-
await new Promise((res) => setTimeout(res, 10));
119
-
}
120
-
},
121
-
});
122
-
123
114
return new Response(
124
-
body.pipeThrough(stagger).pipeThrough(new TextEncoderStream()),
115
+
body.pipeThrough(stagger()).pipeThrough(new TextEncoderStream()),
125
116
{
126
117
headers: {
127
118
"Content-Type": "text/text; charset=utf-8",
···
131
122
}
132
123
);
133
124
}
134
-
135
-
Deno.serve(
136
-
{ port: !Number.isNaN(PORT) && PORT <= 65535 && PORT > 0 ? PORT : 8000 },
137
-
(req) => {
138
-
const path = new URL(req.url).pathname;
139
-
switch (path) {
140
-
case "/":
141
-
return landingPage();
142
-
case "/styles.css":
143
-
return new Response(css, {
144
-
headers: {
145
-
"Content-Type": "text/css; charset=utf-8",
146
-
"Access-Control-Allow-Origin": "*",
147
-
},
148
-
});
149
-
default:
150
-
return new Response(`404 Not Found: ${path} does not exist`, {
151
-
status: 400,
152
-
statusText: "Not Found",
153
-
});
154
-
}
155
-
}
156
-
);
+18
landing/schemas.ts
landing/src/schemas.ts
+18
landing/schemas.ts
landing/src/schemas.ts
···
30
30
}),
31
31
});
32
32
33
+
export const tangledKnotOwnerSchema = z.object({
34
+
owner: z.string(),
35
+
});
36
+
37
+
export const tangledRepoSchema = z.object({
38
+
knot: z.string(),
39
+
name: z.string(),
40
+
description: z.string().optional(),
41
+
website: z.url().optional(),
42
+
});
43
+
44
+
export const slingshotMiniDocSchema = z.object({
45
+
pds: z.string(),
46
+
handle: z.string(),
47
+
did: z.string(),
48
+
});
49
+
33
50
export function getRecordSchema<T>(record: T) {
34
51
return z.object({
35
52
value: record,
···
45
62
value: record,
46
63
})
47
64
),
65
+
cursor: z.string().optional(),
48
66
});
49
67
}
+36
landing/src/index.ts
+36
landing/src/index.ts
···
1
+
import css from "./styles.css" with { type: "text" };
2
+
import pds from "./pds/pds.ts";
3
+
import knot from "./knot/knot.ts";
4
+
5
+
const PORT = Number(Deno.env.get("PORT"));
6
+
7
+
Deno.serve(
8
+
{ port: !Number.isNaN(PORT) && PORT <= 65535 && PORT > 0 ? PORT : 8000 },
9
+
(req) => {
10
+
const path = new URL(req.url).pathname;
11
+
console.log("PATH", path);
12
+
switch (path) {
13
+
case "/styles.css":
14
+
return new Response(css, {
15
+
headers: {
16
+
"Content-Type": "text/css; charset=utf-8",
17
+
"Access-Control-Allow-Origin": "*",
18
+
},
19
+
});
20
+
21
+
case "/pds":
22
+
case "/pds/":
23
+
return pds();
24
+
25
+
case "/knot":
26
+
case "/knot/":
27
+
return knot();
28
+
}
29
+
30
+
console.warn(`Request for ${path} returned 404`);
31
+
return new Response("404 Not Found", {
32
+
status: 404,
33
+
statusText: "Not Found",
34
+
});
35
+
}
36
+
);
+10
landing/src/knot/knot-heading.txt
+10
landing/src/knot/knot-heading.txt
···
1
+
__ ___ _ _ ___ __
2
+
| \ / \| \| |/ __|/__|
3
+
_ | - || - || || | |\__\
4
+
|_||__/ \___/|_|\_|\___||__/
5
+
6
+
7
+
Knot Source: https://tangled.org/tangled.org/core/tree/master/knotserver
8
+
Docs: https://tangled.org/tangled.org/core/tree/master/docs
9
+
.dong file: https://dongs.zip/
10
+
health:
+162
landing/src/knot/knot.ts
+162
landing/src/knot/knot.ts
···
1
+
import z from "zod";
2
+
import heading from "./knot-heading.txt" with { type: "text" };
3
+
import {
4
+
listRecordsSchema,
5
+
slingshotMiniDocSchema,
6
+
tangledKnotOwnerSchema,
7
+
tangledRepoSchema,
8
+
} from "../schemas.ts";
9
+
import { stagger } from "../utils.ts";
10
+
11
+
const KNOT_HOST = Deno.env.get("KNOT_HOST") ?? "http://pi:5555";
12
+
const KNOT_NAME = Deno.env.get("KNOT_NAME") ?? "knot.vielle.dev";
13
+
const SLINGSHOT_INSTANCE =
14
+
Deno.env.get("SLINGSHOT_INSTANCE") ?? "https://slingshot.microcosm.blue";
15
+
16
+
function getAllRecords<T>(
17
+
owner: string,
18
+
pds: string,
19
+
collection: string,
20
+
validator: z.ZodType,
21
+
cursor?: string
22
+
): Promise<{ uri: string; cid: string; value: T }[]> {
23
+
return fetch(
24
+
`${pds}/xrpc/com.atproto.repo.listRecords?repo=${owner}&collection=${collection}&cursor=${cursor}`
25
+
)
26
+
.then((res) => res.json())
27
+
28
+
.then(
29
+
(res) =>
30
+
// check that it matches the schema and assume that the schema matches T
31
+
listRecordsSchema(validator).safeParse(res).data as {
32
+
records: {
33
+
uri: string;
34
+
cid: string;
35
+
value: T;
36
+
}[];
37
+
cursor?: string;
38
+
}
39
+
)
40
+
.then(async (res) => [
41
+
...res.records,
42
+
...(res.cursor
43
+
? await getAllRecords<T>(owner, pds, collection, validator, res.cursor)
44
+
: []),
45
+
]);
46
+
}
47
+
48
+
export default function (): Response {
49
+
// get upstream knot owner/status
50
+
const owner = fetch(`${KNOT_HOST}/xrpc/sh.tangled.owner`);
51
+
const status = owner.then(
52
+
async (res) => `${res.status} ${res.statusText} ${await res.clone().text()}`
53
+
);
54
+
55
+
const ownerDid: Promise<string | undefined> = owner
56
+
.then((res) => res.json())
57
+
.then((x) => tangledKnotOwnerSchema.safeParse(x).data?.owner)
58
+
.catch(() => undefined);
59
+
60
+
const ownerPds = ownerDid
61
+
.then((owner) =>
62
+
fetch(
63
+
`${SLINGSHOT_INSTANCE}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${owner}`
64
+
)
65
+
)
66
+
.then((res) => res.json())
67
+
.then((x) => slingshotMiniDocSchema.safeParse(x).data?.pds);
68
+
69
+
const members = ownerPds
70
+
.then((pds) =>
71
+
ownerDid.then(async (owner) => [
72
+
owner,
73
+
...(await getAllRecords<{ subject: string; domain: string }>(
74
+
owner,
75
+
pds,
76
+
"sh.tangled.knot.member",
77
+
z.object({
78
+
subject: z.string(),
79
+
domain: z.string(),
80
+
})
81
+
).then((x) =>
82
+
x
83
+
.filter((x) => x.value.domain === KNOT_NAME)
84
+
.map((x) => x.value.subject)
85
+
)),
86
+
])
87
+
)
88
+
.then((dids) =>
89
+
Promise.all(
90
+
dids.map((did) =>
91
+
fetch(
92
+
`${SLINGSHOT_INSTANCE}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${did}`
93
+
)
94
+
.then((res) => res.json())
95
+
.then((res) => slingshotMiniDocSchema.safeParse(res).data)
96
+
)
97
+
)
98
+
);
99
+
100
+
const membersRepr = members.then(
101
+
(x) =>
102
+
" - " +
103
+
x
104
+
.map((x) => (x.handle !== "handle.invalid" ? x.handle : x.did))
105
+
.join("\n - ")
106
+
);
107
+
108
+
const repos = members.then((members) =>
109
+
Promise.all(
110
+
members.map((member) =>
111
+
getAllRecords<z.infer<typeof tangledRepoSchema>>(
112
+
member.did,
113
+
member.pds,
114
+
"sh.tangled.repo",
115
+
tangledRepoSchema
116
+
).then((x) =>
117
+
x.map((x) => ({
118
+
...x.value,
119
+
user:
120
+
member.handle !== "handle.invalid" ? member.handle : member.did,
121
+
}))
122
+
)
123
+
)
124
+
).then((x) => x.flat().filter((x) => x.knot === KNOT_NAME))
125
+
);
126
+
127
+
const reposRepr = repos.then(
128
+
(x) =>
129
+
" - " +
130
+
x
131
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
132
+
.map(
133
+
(x) =>
134
+
`${x.user}/${x.name}${x.description ? `\n ${x.description}` : ""}${x.website ? `\n ${x.website}` : ""}`
135
+
)
136
+
.join("\n - ") +
137
+
"\n"
138
+
);
139
+
140
+
const body = new ReadableStream({
141
+
async start(controller) {
142
+
controller.enqueue(heading);
143
+
controller.enqueue(await status);
144
+
controller.enqueue("\n\nMembers:\n");
145
+
controller.enqueue(await membersRepr);
146
+
controller.enqueue("\n\nRepos:\n");
147
+
controller.enqueue(await reposRepr);
148
+
controller.close();
149
+
},
150
+
});
151
+
152
+
return new Response(
153
+
body.pipeThrough(stagger()).pipeThrough(new TextEncoderStream()),
154
+
{
155
+
headers: {
156
+
"Content-Type": "text/text; charset=utf-8",
157
+
"Access-Control-Allow-Origin": "*",
158
+
Link: "</styles.css>; rel=stylesheet",
159
+
},
160
+
}
161
+
);
162
+
}
+16
landing/src/utils.ts
+16
landing/src/utils.ts
···
1
+
/**
2
+
* Delays the output of each character in the input stream by 10ms
3
+
* @returns TransformStream
4
+
*/
5
+
export const stagger = () =>
6
+
new TransformStream({
7
+
async transform(
8
+
chunk: string,
9
+
controller: TransformStreamDefaultController<string>
10
+
) {
11
+
for (const part of chunk) {
12
+
controller.enqueue(part);
13
+
await new Promise((res) => setTimeout(res, 10));
14
+
}
15
+
},
16
+
});
landing/styles.css
landing/src/styles.css
landing/styles.css
landing/src/styles.css