Skip to content

Commit 180317b

Browse files
feat: add server side atproto blog apis (#841)
Co-authored-by: Brandon Hurrington <brandon.o.hurrington@gmail.com>
1 parent 1732f15 commit 180317b

File tree

10 files changed

+536
-9
lines changed

10 files changed

+536
-9
lines changed

modules/standard-site-sync.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { readFileSync } from 'node:fs'
2+
import { createHash } from 'node:crypto'
3+
import { defineNuxtModule, useNuxt, createResolver } from 'nuxt/kit'
4+
import { safeParse } from 'valibot'
5+
import * as site from '../shared/types/lexicons/site'
6+
import { BlogPostSchema } from '../shared/schemas/blog'
7+
import { NPMX_SITE } from '../shared/utils/constants'
8+
import { parseBasicFrontmatter } from '../shared/utils/parse-basic-frontmatter'
9+
import { TID } from '@atproto/common'
10+
import { Client } from '@atproto/lex'
11+
12+
const syncedDocuments = new Map<string, string>()
13+
const CLOCK_ID_THREE = 3
14+
const DATE_TO_MICROSECONDS = 1000
15+
16+
// TODO: Currently logging quite a lot, can remove some later if we want
17+
export default defineNuxtModule({
18+
meta: { name: 'standard-site-sync' },
19+
async setup() {
20+
const nuxt = useNuxt()
21+
const { resolve } = createResolver(import.meta.url)
22+
const contentDir = resolve('../app/pages/blog')
23+
24+
// Authentication with PDS using an app password
25+
const pdsUrl = process.env.NPMX_PDS_URL
26+
if (!pdsUrl) {
27+
console.warn('[standard-site-sync] NPMX_PDS_URL not set, skipping sync')
28+
return
29+
}
30+
// Instantiate a single new client instance that is reused for every file
31+
const client = new Client(pdsUrl)
32+
33+
if (nuxt.options._prepare) return
34+
35+
nuxt.hook('build:before', async () => {
36+
const { glob } = await import('tinyglobby')
37+
const files: string[] = await glob(`${contentDir}/**/*.md`)
38+
39+
// INFO: Arbitrarily chosen concurrency limit, can be changed if needed
40+
const concurrencyLimit = 5
41+
for (let i = 0; i < files.length; i += concurrencyLimit) {
42+
const batch = files.slice(i, i + concurrencyLimit)
43+
// Process files in parallel
44+
await Promise.all(
45+
batch.map(file =>
46+
syncFile(file, NPMX_SITE, client).catch(error =>
47+
console.error(`[standard-site-sync] Error in ${file}:` + error),
48+
),
49+
),
50+
)
51+
}
52+
})
53+
54+
nuxt.hook('builder:watch', async (event, path) => {
55+
if (!path.endsWith('.md')) return
56+
57+
// Ignore deleted files
58+
if (event === 'unlink') {
59+
console.log(`[standard-site-sync] File deleted: ${path}`)
60+
return
61+
}
62+
63+
// Process add/change events only
64+
await syncFile(resolve(nuxt.options.rootDir, path), NPMX_SITE, client).catch(err =>
65+
console.error(`[standard-site-sync] Failed ${path}:`, err),
66+
)
67+
})
68+
},
69+
})
70+
71+
/*
72+
* INFO: Loads record to atproto and ensures uniqueness by checking the date the article is published
73+
* publishedAt is an id that does not change
74+
* Atomicity is enforced with upsert using publishedAt so we always update existing records instead of creating new ones
75+
* Clock id(3) provides a deterministic ID
76+
* WARN: DOES NOT CATCH ERRORS, THIS MUST BE HANDLED
77+
*/
78+
const syncFile = async (filePath: string, siteUrl: string, client: Client) => {
79+
const fileContent = readFileSync(filePath, 'utf-8')
80+
const frontmatter = parseBasicFrontmatter(fileContent)
81+
82+
// Schema expects 'path' & frontmatter provides 'slug'
83+
const normalizedFrontmatter = {
84+
...frontmatter,
85+
path: typeof frontmatter.slug === 'string' ? `/blog/${frontmatter.slug}` : frontmatter.path,
86+
}
87+
88+
const result = safeParse(BlogPostSchema, normalizedFrontmatter)
89+
if (!result.success) {
90+
console.warn(`[standard-site-sync] Validation failed for ${filePath}`, result.issues)
91+
return
92+
}
93+
94+
const data = result.output
95+
96+
// filter drafts
97+
if (data.draft) {
98+
if (process.env.DEBUG === 'true') {
99+
console.debug(`[standard-site-sync] Skipping draft: ${data.path}`)
100+
}
101+
return
102+
}
103+
104+
// Keys are sorted to provide a more stable hash
105+
const hash = createHash('sha256')
106+
.update(JSON.stringify(data, Object.keys(data).sort()))
107+
.digest('hex')
108+
109+
if (syncedDocuments.get(data.path) === hash) {
110+
return
111+
}
112+
113+
const document = site.standard.document.$build({
114+
site: siteUrl as `${string}:${string}`,
115+
path: data.path,
116+
title: data.title,
117+
description: data.description ?? data.excerpt,
118+
tags: data.tags,
119+
// This can be extended to update the site.standard.document .updatedAt if it is changed and use the posts date here
120+
publishedAt: new Date(data.date).toISOString(),
121+
})
122+
123+
const dateInMicroSeconds = new Date(result.output.date).getTime() * DATE_TO_MICROSECONDS
124+
125+
// Clock id(3) needs to be the same everytime to get the same TID from a timestamp
126+
const tid = TID.fromTime(dateInMicroSeconds, CLOCK_ID_THREE)
127+
128+
// client.put is async and needs to be awaited
129+
await client.put(site.standard.document, document, {
130+
rkey: tid.str,
131+
})
132+
133+
syncedDocuments.set(data.path, hash)
134+
}

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"npmx-connector": "pnpm --filter npmx-connector dev",
2626
"generate-pwa-icons": "pwa-assets-generator",
2727
"preview": "nuxt preview",
28-
"postinstall": "nuxt prepare && simple-git-hooks && pnpm generate:lexicons",
28+
"postinstall": "pnpm generate:lexicons && nuxt prepare && simple-git-hooks",
2929
"generate:fixtures": "node scripts/generate-fixtures.ts",
3030
"generate:lexicons": "lex build --lexicons lexicons --out shared/types/lexicons --clear",
3131
"test": "vite test",
@@ -34,12 +34,13 @@
3434
"test:browser:ui": "pnpm build:playwright && pnpm test:browser:prebuilt --ui",
3535
"test:browser:update": "pnpm build:playwright && pnpm test:browser:prebuilt --update-snapshots",
3636
"test:nuxt": "vite test --project nuxt",
37-
"test:types": "nuxt prepare && vue-tsc -b --noEmit && pnpm --filter npmx-connector test:types",
37+
"test:types": "pnpm generate:lexicons && nuxt prepare && vue-tsc -b --noEmit && pnpm --filter npmx-connector test:types",
3838
"test:unit": "vite test --project unit",
3939
"start:playwright:webserver": "NODE_ENV=test pnpm preview --port 5678"
4040
},
4141
"dependencies": {
4242
"@atproto/api": "^0.18.17",
43+
"@atproto/common": "0.5.10",
4344
"@atproto/lex": "0.0.13",
4445
"@atproto/oauth-client-node": "^0.3.15",
4546
"@deno/doc": "jsr:^0.189.1",
@@ -75,6 +76,7 @@
7576
"defu": "6.1.4",
7677
"fast-npm-meta": "1.0.0",
7778
"focus-trap": "^7.8.0",
79+
"tinyglobby": "0.2.15",
7880
"marked": "17.0.1",
7981
"module-replacements": "2.11.0",
8082
"nuxt": "4.3.0",

pnpm-lock.yaml

Lines changed: 21 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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+
const response = await $fetch<ProfilesResponse>(`${BLUESKY_API}app.bsky.actor.getProfiles`, {
61+
query: { actors: handles },
62+
}).catch(() => ({ profiles: [] }))
63+
64+
const avatarMap = new Map<string, string>()
65+
for (const profile of response.profiles) {
66+
if (profile.avatar) {
67+
avatarMap.set(profile.handle, profile.avatar)
68+
}
69+
}
70+
71+
const resolvedAuthors: ResolvedAuthor[] = authors.map(author => ({
72+
...author,
73+
avatar: author.blueskyHandle ? avatarMap.get(author.blueskyHandle) || null : null,
74+
profileUrl: author.blueskyHandle ? `https://bsky.app/profile/${author.blueskyHandle}` : null,
75+
}))
76+
77+
return { authors: resolvedAuthors }
78+
},
79+
{
80+
name: 'author-profiles',
81+
maxAge: CACHE_MAX_AGE_ONE_DAY,
82+
getKey: event => {
83+
const { authors } = getQuery(event)
84+
return `author-profiles:${authors ?? 'npmx.dev'}`
85+
},
86+
},
87+
)

0 commit comments

Comments
 (0)