Skip to content

Commit 8d06923

Browse files
feat: add server side atproto blog apis
This pulls apart the feature work done on this PR #257. In this commit, we're adding the atproto server side blog apis that will fetch bluesky comments and author profiles. This will be used in a later PR that will implement the frontend blog functionality. Co-authored-by: Brandon Hurrington <brandon.o.hurrington@gmail.com>
1 parent bdaaa2c commit 8d06923

7 files changed

Lines changed: 390 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import * as v from 'valibot'
2+
import { CACHE_MAX_AGE_ONE_DAY, BLUESKY_API } from '#shared/utils/constants'
3+
import { AuthorSchema } from '#shared/schemas/blog'
4+
import type { Author, ResolvedAuthor } from '#shared/schemas/blog'
5+
6+
type ProfilesResponse = {
7+
profiles: Array<{
8+
did: string
9+
handle: string
10+
displayName?: string
11+
avatar?: string
12+
}>
13+
}
14+
15+
export default defineCachedEventHandler(
16+
async event => {
17+
const query = getQuery(event)
18+
const authorsParam = query.authors
19+
20+
if (!authorsParam || typeof authorsParam !== 'string') {
21+
throw createError({
22+
statusCode: 400,
23+
statusMessage: 'authors query parameter is required (JSON array)',
24+
})
25+
}
26+
27+
let authors: Author[]
28+
try {
29+
const parsed = JSON.parse(authorsParam)
30+
authors = v.parse(v.array(AuthorSchema), parsed)
31+
} catch (error) {
32+
if (error instanceof v.ValiError) {
33+
throw createError({
34+
statusCode: 400,
35+
statusMessage: `Invalid authors format: ${error.message}`,
36+
})
37+
}
38+
throw createError({
39+
statusCode: 400,
40+
statusMessage: 'authors must be valid JSON',
41+
})
42+
}
43+
44+
if (!Array.isArray(authors) || authors.length === 0) {
45+
return { authors: [] }
46+
}
47+
48+
const handles = authors.filter(a => a.blueskyHandle).map(a => a.blueskyHandle as string)
49+
50+
if (handles.length === 0) {
51+
return {
52+
authors: authors.map(author => ({
53+
...author,
54+
avatar: null,
55+
profileUrl: null,
56+
})),
57+
}
58+
}
59+
60+
try {
61+
const response = await $fetch<ProfilesResponse>(`${BLUESKY_API}app.bsky.actor.getProfiles`, {
62+
query: { actors: handles },
63+
})
64+
65+
const avatarMap = new Map<string, string>()
66+
for (const profile of response.profiles) {
67+
if (profile.avatar) {
68+
avatarMap.set(profile.handle, profile.avatar)
69+
}
70+
}
71+
72+
const resolvedAuthors: ResolvedAuthor[] = authors.map(author => ({
73+
...author,
74+
avatar: author.blueskyHandle ? avatarMap.get(author.blueskyHandle) || null : null,
75+
profileUrl: author.blueskyHandle
76+
? `https://bsky.app/profile/${author.blueskyHandle}`
77+
: null,
78+
}))
79+
80+
return { authors: resolvedAuthors }
81+
} catch (error) {
82+
console.error('[Author Profiles] Failed to fetch profiles:', error)
83+
return {
84+
authors: authors.map(author => ({
85+
...author,
86+
avatar: null,
87+
profileUrl: author.blueskyHandle
88+
? `https://bsky.app/profile/${author.blueskyHandle}`
89+
: null,
90+
})),
91+
}
92+
}
93+
},
94+
{
95+
name: 'author-profiles',
96+
maxAge: CACHE_MAX_AGE_ONE_DAY,
97+
getKey: event => {
98+
const { authors } = getQuery(event)
99+
return `author-profiles:${authors}`
100+
},
101+
},
102+
)
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { safeParse, flatten } from 'valibot'
2+
import type { Comment, CommentEmbed } from '#shared/types/blog-post'
3+
import {
4+
AppBskyFeedDefs,
5+
AppBskyFeedPost,
6+
AppBskyEmbedImages,
7+
AppBskyEmbedExternal,
8+
} from '@atproto/api'
9+
import { BlueSkyUriSchema } from '#shared/schemas/atproto'
10+
import { CACHE_MAX_AGE_ONE_MINUTE, BLUESKY_API, AT_URI_REGEX } from '#shared/utils/constants'
11+
12+
import { jsonToLex } from '@atproto/api'
13+
14+
type ThreadResponse = { thread: AppBskyFeedDefs.ThreadViewPost }
15+
16+
type LikesResponse = {
17+
likes: Array<{
18+
actor: {
19+
did: string
20+
handle: string
21+
displayName?: string
22+
avatar?: string
23+
}
24+
}>
25+
}
26+
27+
type PostsResponse = { posts: Array<{ likeCount?: number }> }
28+
29+
/**
30+
* Provides both build and runtime comments refreshes
31+
* During build, cache aggressively to avoid rate limits
32+
* During runtime, refresh cache once every minute
33+
*/
34+
export default defineCachedEventHandler(
35+
async event => {
36+
const query = getQuery(event)
37+
const parsed = safeParse(BlueSkyUriSchema, query)
38+
39+
if (!parsed.success) {
40+
throw createError({
41+
statusCode: 400,
42+
statusMessage: `Invalid URI format: ${flatten(parsed.issues).root?.[0] || 'Must be a valid at:// URI'}`,
43+
})
44+
}
45+
46+
const { uri } = parsed.output
47+
48+
try {
49+
// Fetch thread, likes, and post metadata in parallel
50+
const [threadResponse, likesResponse, postsResponse] = await Promise.all([
51+
$fetch<ThreadResponse>(`${BLUESKY_API}app.bsky.feed.getPostThread`, {
52+
query: { uri, depth: 10 },
53+
}).catch((err: Error) => {
54+
console.warn(`[Bluesky] Thread fetch failed for ${uri}:`, err.message)
55+
return null
56+
}),
57+
58+
$fetch<LikesResponse>(`${BLUESKY_API}app.bsky.feed.getLikes`, {
59+
query: { uri, limit: 50 },
60+
}).catch(() => ({ likes: [] })),
61+
62+
$fetch<PostsResponse>(`${BLUESKY_API}app.bsky.feed.getPosts`, {
63+
query: { uris: [uri] },
64+
}).catch(() => ({ posts: [] })),
65+
])
66+
67+
// Early return if thread fetch fails w/o 404
68+
if (!threadResponse) {
69+
return {
70+
thread: null,
71+
likes: [],
72+
totalLikes: 0,
73+
postUrl: atUriToWebUrl(uri),
74+
_empty: true,
75+
}
76+
}
77+
78+
const thread = parseThread(threadResponse.thread)
79+
80+
return {
81+
thread,
82+
likes: likesResponse.likes || [],
83+
totalLikes: postsResponse.posts?.[0]?.likeCount || thread?.likeCount || 0,
84+
postUrl: atUriToWebUrl(uri),
85+
}
86+
} catch (error) {
87+
// Fail open during build to prevent build breakage
88+
console.error('[Bluesky] Unexpected error:', error)
89+
return {
90+
thread: null,
91+
likes: [],
92+
totalLikes: 0,
93+
postUrl: atUriToWebUrl(uri),
94+
_error: true,
95+
}
96+
}
97+
},
98+
{
99+
name: 'bluesky-comments',
100+
maxAge: CACHE_MAX_AGE_ONE_MINUTE,
101+
getKey: event => {
102+
const { uri } = getQuery(event)
103+
return `bluesky:${uri}`
104+
},
105+
},
106+
)
107+
108+
// Helper to convert AT URI to web URL
109+
function atUriToWebUrl(uri: string): string | null {
110+
const match = uri.match(AT_URI_REGEX)
111+
if (!match) return null
112+
const [, did, rkey] = match
113+
return `https://bsky.app/profile/${did}/post/${rkey}`
114+
}
115+
116+
function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): CommentEmbed | undefined {
117+
if (!embed) return undefined
118+
119+
if (AppBskyEmbedImages.isView(embed)) {
120+
return {
121+
type: 'images',
122+
images: embed.images,
123+
}
124+
}
125+
126+
if (AppBskyEmbedExternal.isView(embed)) {
127+
return {
128+
type: 'external',
129+
external: embed.external,
130+
}
131+
}
132+
133+
return undefined
134+
}
135+
136+
function parseThread(thread: AppBskyFeedDefs.ThreadViewPost): Comment | null {
137+
if (!AppBskyFeedDefs.isThreadViewPost(thread)) return null
138+
139+
const { post } = thread
140+
141+
// This casts our external.thumb as a blobRef which is needed to validateRecord
142+
const lexPostRecord = jsonToLex(post.record)
143+
const recordValidation = AppBskyFeedPost.validateRecord(lexPostRecord)
144+
145+
if (!recordValidation.success) return null
146+
const record = recordValidation.value
147+
148+
const replies: Comment[] = []
149+
if (thread.replies) {
150+
for (const reply of thread.replies) {
151+
if (AppBskyFeedDefs.isThreadViewPost(reply)) {
152+
const parsed = parseThread(reply)
153+
if (parsed) replies.push(parsed)
154+
}
155+
}
156+
replies.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
157+
}
158+
159+
return {
160+
uri: post.uri,
161+
cid: post.cid,
162+
author: {
163+
did: post.author.did,
164+
handle: post.author.handle,
165+
displayName: post.author.displayName,
166+
avatar: post.author.avatar,
167+
},
168+
text: record.text,
169+
facets: record.facets,
170+
embed: parseEmbed(post.embed),
171+
createdAt: record.createdAt,
172+
likeCount: post.likeCount ?? 0,
173+
replyCount: post.replyCount ?? 0,
174+
repostCount: post.repostCount ?? 0,
175+
replies,
176+
}
177+
}

shared/schemas/atproto.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { object, string, startsWith, minLength, regex, pipe } from 'valibot'
2+
import type { InferOutput } from 'valibot'
3+
import { AT_URI_REGEX } from '#shared/utils/constants'
4+
5+
export const BlueSkyUriSchema = object({
6+
uri: pipe(
7+
string(),
8+
startsWith('at://'),
9+
minLength(10),
10+
regex(AT_URI_REGEX, 'Must be a valid at:// URI'),
11+
),
12+
})
13+
14+
export type BlueSkyUri = InferOutput<typeof BlueSkyUriSchema>

shared/schemas/blog.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { object, string, optional, array, boolean, pipe, isoDate } from 'valibot'
2+
import type { InferOutput } from 'valibot'
3+
4+
export const AuthorSchema = object({
5+
name: string(),
6+
blueskyHandle: optional(string()),
7+
})
8+
9+
export const BlogPostSchema = object({
10+
authors: array(AuthorSchema),
11+
title: string(),
12+
date: pipe(string(), isoDate()),
13+
description: string(),
14+
path: string(),
15+
slug: string(),
16+
excerpt: optional(string()),
17+
tags: optional(array(string())),
18+
draft: optional(boolean()),
19+
})
20+
21+
export type Author = InferOutput<typeof AuthorSchema>
22+
23+
export interface ResolvedAuthor extends Author {
24+
avatar: string | null
25+
profileUrl: string | null
26+
}
27+
28+
/**
29+
* Inferred type for blog post frontmatter
30+
*/
31+
/** @public */
32+
export type BlogPostFrontmatter = InferOutput<typeof BlogPostSchema>

shared/types/blog-post.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type {
2+
AppBskyActorDefs,
3+
AppBskyRichtextFacet,
4+
AppBskyEmbedImages,
5+
AppBskyEmbedExternal,
6+
} from '@atproto/api'
7+
export interface CommentEmbed {
8+
type: 'images' | 'external'
9+
images?: AppBskyEmbedImages.ViewImage[]
10+
external?: AppBskyEmbedExternal.ViewExternal
11+
}
12+
13+
export interface Comment {
14+
uri: string
15+
cid: string
16+
author: Pick<AppBskyActorDefs.ProfileViewBasic, 'did' | 'handle' | 'displayName' | 'avatar'>
17+
text: string
18+
facets?: AppBskyRichtextFacet.Main[]
19+
embed?: CommentEmbed
20+
createdAt: string
21+
likeCount: number
22+
replyCount: number
23+
repostCount: number
24+
replies: Comment[]
25+
}

shared/utils/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export const CACHE_MAX_AGE_ONE_DAY = 60 * 60 * 24
88
export const CACHE_MAX_AGE_ONE_YEAR = 60 * 60 * 24 * 365
99

1010
// API Strings
11+
export const NPMX_SITE = 'https://npmx.dev'
12+
export const BLUESKY_API = 'https://public.api.bsky.app/xrpc/'
13+
export const BLUESKY_COMMENTS_REQUEST = '/api/atproto/bluesky-comments'
1114
export const NPM_REGISTRY = 'https://registry.npmjs.org'
1215
export const ERROR_PACKAGE_ANALYSIS_FAILED = 'Failed to analyze package.'
1316
export const ERROR_PACKAGE_VERSION_AND_FILE_FAILED = 'Version and file path are required.'
@@ -57,3 +60,6 @@ export const BACKGROUND_THEMES = {
5760
slate: 'oklch(0.555 0.046 257.407)',
5861
black: 'oklch(0.4 0 0)',
5962
} as const
63+
64+
// Regex
65+
export const AT_URI_REGEX = /^at:\/\/(did:plc:[a-z0-9]+)\/app\.bsky\.feed\.post\/([a-z0-9]+)$/

0 commit comments

Comments
 (0)