tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
27
pulls
pipelines
prefetch bluesky data and cache at edge
awarm.space
4 months ago
a86ebb9b
ef4542c7
+159
-85
5 changed files
expand all
collapse all
unified
split
app
api
bsky
hydrate
route.ts
lish
[did]
[publication]
[rkey]
Interactions
Interactions.tsx
Quotes.tsx
getBlueskyMentions.ts
getPostPageData.ts
+75
app/api/bsky/hydrate/route.ts
···
1
1
+
import { Agent, lexToJson } from "@atproto/api";
2
2
+
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
3
3
+
import { NextRequest } from "next/server";
4
4
+
5
5
+
export const runtime = "nodejs";
6
6
+
7
7
+
export async function GET(req: NextRequest) {
8
8
+
try {
9
9
+
const searchParams = req.nextUrl.searchParams;
10
10
+
const urisParam = searchParams.get("uris");
11
11
+
12
12
+
if (!urisParam) {
13
13
+
return Response.json(
14
14
+
{ error: "uris parameter is required" },
15
15
+
{ status: 400 },
16
16
+
);
17
17
+
}
18
18
+
19
19
+
// Parse URIs from JSON string
20
20
+
let uris: string[];
21
21
+
try {
22
22
+
uris = JSON.parse(urisParam);
23
23
+
} catch (e) {
24
24
+
return Response.json(
25
25
+
{ error: "uris must be valid JSON array" },
26
26
+
{ status: 400 },
27
27
+
);
28
28
+
}
29
29
+
30
30
+
if (!Array.isArray(uris)) {
31
31
+
return Response.json({ error: "uris must be an array" }, { status: 400 });
32
32
+
}
33
33
+
34
34
+
if (uris.length === 0) {
35
35
+
return Response.json([], {
36
36
+
headers: {
37
37
+
"Cache-Control": "public, s-maxage=600, stale-while-revalidate=3600",
38
38
+
},
39
39
+
});
40
40
+
}
41
41
+
42
42
+
// Hydrate Bluesky URIs with post data
43
43
+
let agent = new Agent({
44
44
+
service: "https://public.api.bsky.app",
45
45
+
});
46
46
+
47
47
+
// Process URIs in batches of 25
48
48
+
let allPostRequests = [];
49
49
+
for (let i = 0; i < uris.length; i += 25) {
50
50
+
let batch = uris.slice(i, i + 25);
51
51
+
let batchPosts = agent.getPosts(
52
52
+
{
53
53
+
uris: batch,
54
54
+
},
55
55
+
{ headers: {} },
56
56
+
);
57
57
+
allPostRequests.push(batchPosts);
58
58
+
}
59
59
+
let allPosts = (await Promise.all(allPostRequests)).flatMap(
60
60
+
(r) => r.data.posts,
61
61
+
);
62
62
+
63
63
+
const posts = lexToJson(allPosts) as PostView[];
64
64
+
65
65
+
return Response.json(posts, {
66
66
+
headers: {
67
67
+
// Cache for 1 hour on CDN, allow stale content for 24 hours while revalidating
68
68
+
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
69
69
+
},
70
70
+
});
71
71
+
} catch (error) {
72
72
+
console.error("Error hydrating Bluesky posts:", error);
73
73
+
return Response.json({ error: "Failed to hydrate posts" }, { status: 500 });
74
74
+
}
75
75
+
}
+9
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
···
11
11
import { scrollIntoView } from "src/utils/scrollIntoView";
12
12
import { PostPageData } from "../getPostPageData";
13
13
import { PubLeafletComment } from "lexicons/api";
14
14
+
import { prefetchQuotesData } from "./Quotes";
14
15
15
16
export type InteractionState = {
16
17
drawerOpen: undefined | boolean;
···
110
111
111
112
let { drawerOpen, drawer, pageId } = useInteractionState(document_uri);
112
113
114
114
+
const handleQuotePrefetch = () => {
115
115
+
if (data?.quotesAndMentions) {
116
116
+
prefetchQuotesData(data.quotesAndMentions);
117
117
+
}
118
118
+
};
119
119
+
113
120
return (
114
121
<div
115
122
className={`flex gap-2 text-tertiary ${props.compact ? "text-sm" : "px-3 sm:px-4"} ${props.className}`}
···
121
128
openInteractionDrawer("quotes", document_uri, props.pageId);
122
129
else setInteractionState(document_uri, { drawerOpen: false });
123
130
}}
131
131
+
onMouseEnter={handleQuotePrefetch}
132
132
+
onTouchStart={handleQuotePrefetch}
124
133
aria-label="Post quotes"
125
134
>
126
135
<QuoteTiny aria-hidden /> {props.quotesCount}{" "}
+38
-4
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
···
20
20
import { ProfileViewBasic } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
21
21
import { flushSync } from "react-dom";
22
22
import { openPage } from "../PostPages";
23
23
-
import useSWR from "swr";
24
24
-
import { hydrateBlueskyPosts } from "./getBlueskyMentions";
23
23
+
import useSWR, { mutate } from "swr";
25
24
import { DotLoader } from "components/utils/DotLoader";
26
25
26
26
+
// Helper to get SWR key for quotes
27
27
+
export function getQuotesSWRKey(uris: string[]) {
28
28
+
if (uris.length === 0) return null;
29
29
+
const params = new URLSearchParams({
30
30
+
uris: JSON.stringify(uris),
31
31
+
});
32
32
+
return `/api/bsky/hydrate?${params.toString()}`;
33
33
+
}
34
34
+
35
35
+
// Fetch posts from API route
36
36
+
async function fetchBskyPosts(uris: string[]): Promise<PostView[]> {
37
37
+
const params = new URLSearchParams({
38
38
+
uris: JSON.stringify(uris),
39
39
+
});
40
40
+
41
41
+
const response = await fetch(`/api/bsky/hydrate?${params.toString()}`);
42
42
+
43
43
+
if (!response.ok) {
44
44
+
throw new Error("Failed to fetch Bluesky posts");
45
45
+
}
46
46
+
47
47
+
return response.json();
48
48
+
}
49
49
+
50
50
+
// Prefetch quotes data
51
51
+
export function prefetchQuotesData(quotesAndMentions: { uri: string; link?: string }[]) {
52
52
+
const uris = quotesAndMentions.map((q) => q.uri);
53
53
+
const key = getQuotesSWRKey(uris);
54
54
+
if (key) {
55
55
+
// Start fetching without blocking
56
56
+
mutate(key, fetchBskyPosts(uris), { revalidate: false });
57
57
+
}
58
58
+
}
59
59
+
27
60
export const Quotes = (props: {
28
61
quotesAndMentions: { uri: string; link?: string }[];
29
62
did: string;
···
35
68
36
69
// Fetch Bluesky post data for all URIs
37
70
const uris = props.quotesAndMentions.map((q) => q.uri);
71
71
+
const key = getQuotesSWRKey(uris);
38
72
const { data: bskyPosts, isLoading } = useSWR(
39
39
-
uris.length > 0 ? JSON.stringify(uris) : null,
40
40
-
() => hydrateBlueskyPosts(uris),
73
73
+
key,
74
74
+
() => fetchBskyPosts(uris),
41
75
);
42
76
43
77
// Separate quotes with links (quoted content) from direct mentions
-80
app/lish/[did]/[publication]/[rkey]/Interactions/getBlueskyMentions.ts
···
1
1
-
"use server";
2
2
-
3
3
-
import { AtUri, Agent, lexToJson } from "@atproto/api";
4
4
-
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
5
5
-
6
6
-
type ConstellationResponse = {
7
7
-
records: { did: string; collection: string; rkey: string }[];
8
8
-
};
9
9
-
10
10
-
const headers = {
11
11
-
"Content-type": "application/json",
12
12
-
"user-agent": "leaflet.pub",
13
13
-
};
14
14
-
15
15
-
// Fetch constellation backlinks without hydrating with Bluesky post data
16
16
-
export async function getConstellationBacklinks(
17
17
-
url: string,
18
18
-
): Promise<{ uri: string }[]> {
19
19
-
let baseURL = `https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(url)}`;
20
20
-
let externalEmbeds = new URL(
21
21
-
`${baseURL}&source=${encodeURIComponent("app.bsky.feed.post:embed.external.uri")}`,
22
22
-
);
23
23
-
let linkFacets = new URL(
24
24
-
`${baseURL}&source=${encodeURIComponent("app.bsky.feed.post:facets[].features[app.bsky.richtext.facet#link].uri")}`,
25
25
-
);
26
26
-
27
27
-
let [links, embeds] = (await Promise.all([
28
28
-
fetch(linkFacets, { headers, next: { revalidate: 3600 } }).then((req) =>
29
29
-
req.json(),
30
30
-
),
31
31
-
fetch(externalEmbeds, { headers, next: { revalidate: 3600 } }).then((req) =>
32
32
-
req.json(),
33
33
-
),
34
34
-
])) as ConstellationResponse[];
35
35
-
36
36
-
let uris = [...links.records, ...embeds.records].map((i) =>
37
37
-
AtUri.make(i.did, i.collection, i.rkey).toString(),
38
38
-
);
39
39
-
40
40
-
return uris.map((uri) => ({ uri }));
41
41
-
}
42
42
-
43
43
-
// Hydrate Bluesky URIs with post data
44
44
-
export async function hydrateBlueskyPosts(uris: string[]): Promise<PostView[]> {
45
45
-
if (uris.length === 0) return [];
46
46
-
47
47
-
let agent = new Agent({
48
48
-
service: "https://public.api.bsky.app",
49
49
-
fetch: (...args) =>
50
50
-
fetch(args[0], {
51
51
-
...args[1],
52
52
-
next: { revalidate: 3600 },
53
53
-
}),
54
54
-
});
55
55
-
56
56
-
// Process URIs in batches of 25
57
57
-
let allPostRequests = [];
58
58
-
for (let i = 0; i < uris.length; i += 25) {
59
59
-
let batch = uris.slice(i, i + 25);
60
60
-
let batchPosts = agent.getPosts(
61
61
-
{
62
62
-
uris: batch,
63
63
-
},
64
64
-
{ headers: {} },
65
65
-
);
66
66
-
allPostRequests.push(batchPosts);
67
67
-
}
68
68
-
let allPosts = (await Promise.all(allPostRequests)).flatMap(
69
69
-
(r) => r.data.posts,
70
70
-
);
71
71
-
72
72
-
return lexToJson(allPosts) as PostView[];
73
73
-
}
74
74
-
75
75
-
// Legacy function - kept for backwards compatibility if needed
76
76
-
export async function getMentions(url: string) {
77
77
-
let backlinks = await getConstellationBacklinks(url);
78
78
-
let uris = backlinks.map((b) => b.uri);
79
79
-
return hydrateBlueskyPosts(uris);
80
80
-
}
+37
-1
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
···
1
1
import { supabaseServerClient } from "supabase/serverClient";
2
2
import { AtUri } from "@atproto/syntax";
3
3
-
import { getConstellationBacklinks } from "./Interactions/getBlueskyMentions";
4
3
import { PubLeafletPublication } from "lexicons/api";
5
4
6
5
export async function getPostPageData(uri: string) {
···
46
45
}
47
46
48
47
export type PostPageData = Awaited<ReturnType<typeof getPostPageData>>;
48
48
+
49
49
+
const headers = {
50
50
+
"Content-type": "application/json",
51
51
+
"user-agent": "leaflet.pub",
52
52
+
};
53
53
+
54
54
+
// Fetch constellation backlinks without hydrating with Bluesky post data
55
55
+
export async function getConstellationBacklinks(
56
56
+
url: string,
57
57
+
): Promise<{ uri: string }[]> {
58
58
+
let baseURL = `https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(url)}`;
59
59
+
let externalEmbeds = new URL(
60
60
+
`${baseURL}&source=${encodeURIComponent("app.bsky.feed.post:embed.external.uri")}`,
61
61
+
);
62
62
+
let linkFacets = new URL(
63
63
+
`${baseURL}&source=${encodeURIComponent("app.bsky.feed.post:facets[].features[app.bsky.richtext.facet#link].uri")}`,
64
64
+
);
65
65
+
66
66
+
let [links, embeds] = (await Promise.all([
67
67
+
fetch(linkFacets, { headers, next: { revalidate: 3600 } }).then((req) =>
68
68
+
req.json(),
69
69
+
),
70
70
+
fetch(externalEmbeds, { headers, next: { revalidate: 3600 } }).then((req) =>
71
71
+
req.json(),
72
72
+
),
73
73
+
])) as ConstellationResponse[];
74
74
+
75
75
+
let uris = [...links.records, ...embeds.records].map((i) =>
76
76
+
AtUri.make(i.did, i.collection, i.rkey).toString(),
77
77
+
);
78
78
+
79
79
+
return uris.map((uri) => ({ uri }));
80
80
+
}
81
81
+
82
82
+
type ConstellationResponse = {
83
83
+
records: { did: string; collection: string; rkey: string }[];
84
84
+
};