a tool for shared writing and social publishing

prefetch bluesky data and cache at edge

+159 -85
+75
app/api/bsky/hydrate/route.ts
··· 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 11 import { scrollIntoView } from "src/utils/scrollIntoView"; 12 12 import { PostPageData } from "../getPostPageData"; 13 13 import { PubLeafletComment } from "lexicons/api"; 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 + const handleQuotePrefetch = () => { 115 + if (data?.quotesAndMentions) { 116 + prefetchQuotesData(data.quotesAndMentions); 117 + } 118 + }; 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 + onMouseEnter={handleQuotePrefetch} 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 - import useSWR from "swr"; 24 - import { hydrateBlueskyPosts } from "./getBlueskyMentions"; 23 + import useSWR, { mutate } from "swr"; 25 24 import { DotLoader } from "components/utils/DotLoader"; 26 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 + 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 + const key = getQuotesSWRKey(uris); 38 72 const { data: bskyPosts, isLoading } = useSWR( 39 - uris.length > 0 ? JSON.stringify(uris) : null, 40 - () => hydrateBlueskyPosts(uris), 73 + key, 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 - "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 - }
+37 -1
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { AtUri } from "@atproto/syntax"; 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 + 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 + };