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