-
-
Notifications
You must be signed in to change notification settings - Fork 203
feat: add server side atproto blog apis #841
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8d06923
4ff7237
6e72b48
7cf7dc2
d7fa4fe
36c0217
dc35f59
038cbc3
70bb09f
abd7526
44aac9b
302d686
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,134 @@ | ||||||||||||||||||||||||||||||||||||
| import { readFileSync } from 'node:fs' | ||||||||||||||||||||||||||||||||||||
| import { createHash } from 'node:crypto' | ||||||||||||||||||||||||||||||||||||
| import { defineNuxtModule, useNuxt, createResolver } from 'nuxt/kit' | ||||||||||||||||||||||||||||||||||||
| import { safeParse } from 'valibot' | ||||||||||||||||||||||||||||||||||||
| import * as site from '../shared/types/lexicons/site' | ||||||||||||||||||||||||||||||||||||
| import { BlogPostSchema } from '../shared/schemas/blog' | ||||||||||||||||||||||||||||||||||||
| import { NPMX_SITE } from '../shared/utils/constants' | ||||||||||||||||||||||||||||||||||||
| import { parseBasicFrontmatter } from '../shared/utils/parse-basic-frontmatter' | ||||||||||||||||||||||||||||||||||||
| import { TID } from '@atproto/common' | ||||||||||||||||||||||||||||||||||||
| import { Client } from '@atproto/lex' | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const syncedDocuments = new Map<string, string>() | ||||||||||||||||||||||||||||||||||||
| const CLOCK_ID_THREE = 3 | ||||||||||||||||||||||||||||||||||||
| const DATE_TO_MICROSECONDS = 1000 | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // TODO: Currently logging quite a lot, can remove some later if we want | ||||||||||||||||||||||||||||||||||||
| export default defineNuxtModule({ | ||||||||||||||||||||||||||||||||||||
| meta: { name: 'standard-site-sync' }, | ||||||||||||||||||||||||||||||||||||
| async setup() { | ||||||||||||||||||||||||||||||||||||
| const nuxt = useNuxt() | ||||||||||||||||||||||||||||||||||||
| const { resolve } = createResolver(import.meta.url) | ||||||||||||||||||||||||||||||||||||
| const contentDir = resolve('../app/pages/blog') | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Authentication with PDS using an app password | ||||||||||||||||||||||||||||||||||||
| const pdsUrl = process.env.NPMX_PDS_URL | ||||||||||||||||||||||||||||||||||||
| if (!pdsUrl) { | ||||||||||||||||||||||||||||||||||||
| console.warn('[standard-site-sync] NPMX_PDS_URL not set, skipping sync') | ||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| // Instantiate a single new client instance that is reused for every file | ||||||||||||||||||||||||||||||||||||
| const client = new Client(pdsUrl) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if (nuxt.options._prepare) return | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| nuxt.hook('build:before', async () => { | ||||||||||||||||||||||||||||||||||||
| const { glob } = await import('tinyglobby') | ||||||||||||||||||||||||||||||||||||
| const files: string[] = await glob(`${contentDir}/**/*.md`) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // INFO: Arbitrarily chosen concurrency limit, can be changed if needed | ||||||||||||||||||||||||||||||||||||
| const concurrencyLimit = 5 | ||||||||||||||||||||||||||||||||||||
| for (let i = 0; i < files.length; i += concurrencyLimit) { | ||||||||||||||||||||||||||||||||||||
| const batch = files.slice(i, i + concurrencyLimit) | ||||||||||||||||||||||||||||||||||||
| // Process files in parallel | ||||||||||||||||||||||||||||||||||||
| await Promise.all( | ||||||||||||||||||||||||||||||||||||
| batch.map(file => | ||||||||||||||||||||||||||||||||||||
| syncFile(file, NPMX_SITE, client).catch(error => | ||||||||||||||||||||||||||||||||||||
| console.error(`[standard-site-sync] Error in ${file}:` + error), | ||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+46
to
+48
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Error logging uses string concatenation with error object. Using 🔧 Proposed fix syncFile(file, NPMX_SITE, client).catch(error =>
- console.error(`[standard-site-sync] Error in ${file}:` + error),
+ console.error(`[standard-site-sync] Error in ${file}:`, error),
),📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| nuxt.hook('builder:watch', async (event, path) => { | ||||||||||||||||||||||||||||||||||||
| if (!path.endsWith('.md')) return | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Ignore deleted files | ||||||||||||||||||||||||||||||||||||
| if (event === 'unlink') { | ||||||||||||||||||||||||||||||||||||
| console.log(`[standard-site-sync] File deleted: ${path}`) | ||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Process add/change events only | ||||||||||||||||||||||||||||||||||||
| await syncFile(resolve(nuxt.options.rootDir, path), NPMX_SITE, client).catch(err => | ||||||||||||||||||||||||||||||||||||
| console.error(`[standard-site-sync] Failed ${path}:`, err), | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| /* | ||||||||||||||||||||||||||||||||||||
| * INFO: Loads record to atproto and ensures uniqueness by checking the date the article is published | ||||||||||||||||||||||||||||||||||||
| * publishedAt is an id that does not change | ||||||||||||||||||||||||||||||||||||
| * Atomicity is enforced with upsert using publishedAt so we always update existing records instead of creating new ones | ||||||||||||||||||||||||||||||||||||
| * Clock id(3) provides a deterministic ID | ||||||||||||||||||||||||||||||||||||
| * WARN: DOES NOT CATCH ERRORS, THIS MUST BE HANDLED | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| const syncFile = async (filePath: string, siteUrl: string, client: Client) => { | ||||||||||||||||||||||||||||||||||||
| const fileContent = readFileSync(filePath, 'utf-8') | ||||||||||||||||||||||||||||||||||||
| const frontmatter = parseBasicFrontmatter(fileContent) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Schema expects 'path' & frontmatter provides 'slug' | ||||||||||||||||||||||||||||||||||||
| const normalizedFrontmatter = { | ||||||||||||||||||||||||||||||||||||
| ...frontmatter, | ||||||||||||||||||||||||||||||||||||
| path: typeof frontmatter.slug === 'string' ? `/blog/${frontmatter.slug}` : frontmatter.path, | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const result = safeParse(BlogPostSchema, normalizedFrontmatter) | ||||||||||||||||||||||||||||||||||||
| if (!result.success) { | ||||||||||||||||||||||||||||||||||||
| console.warn(`[standard-site-sync] Validation failed for ${filePath}`, result.issues) | ||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const data = result.output | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // filter drafts | ||||||||||||||||||||||||||||||||||||
| if (data.draft) { | ||||||||||||||||||||||||||||||||||||
| if (process.env.DEBUG === 'true') { | ||||||||||||||||||||||||||||||||||||
| console.debug(`[standard-site-sync] Skipping draft: ${data.path}`) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Keys are sorted to provide a more stable hash | ||||||||||||||||||||||||||||||||||||
| const hash = createHash('sha256') | ||||||||||||||||||||||||||||||||||||
| .update(JSON.stringify(data, Object.keys(data).sort())) | ||||||||||||||||||||||||||||||||||||
| .digest('hex') | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if (syncedDocuments.get(data.path) === hash) { | ||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const document = site.standard.document.$build({ | ||||||||||||||||||||||||||||||||||||
| site: siteUrl as `${string}:${string}`, | ||||||||||||||||||||||||||||||||||||
| path: data.path, | ||||||||||||||||||||||||||||||||||||
| title: data.title, | ||||||||||||||||||||||||||||||||||||
| description: data.description ?? data.excerpt, | ||||||||||||||||||||||||||||||||||||
| tags: data.tags, | ||||||||||||||||||||||||||||||||||||
| // This can be extended to update the site.standard.document .updatedAt if it is changed and use the posts date here | ||||||||||||||||||||||||||||||||||||
| publishedAt: new Date(data.date).toISOString(), | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+113
to
+121
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: rg -n 'NPMX_SITE' --type ts -A 2 -B 2Repository: npmx-dev/npmx.dev Length of output: 1595 🏁 Script executed: cd modules && head -c 5000 standard-site-sync.ts | cat -nRepository: npmx-dev/npmx.dev Length of output: 5648 🏁 Script executed: rg -n 'siteUrl' modules/standard-site-sync.ts -B 3 -A 3Repository: npmx-dev/npmx.dev Length of output: 599 🏁 Script executed: fd -t ts -e ts . -x grep -l 'NPMX_SITE' {}Repository: npmx-dev/npmx.dev Length of output: 230 Improve type safety for the The type assertion Consider either constraining the parameter type using a template literal type (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Kai-ros is this comment valid?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const dateInMicroSeconds = new Date(result.output.date).getTime() * DATE_TO_MICROSECONDS | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // Clock id(3) needs to be the same everytime to get the same TID from a timestamp | ||||||||||||||||||||||||||||||||||||
| const tid = TID.fromTime(dateInMicroSeconds, CLOCK_ID_THREE) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // client.put is async and needs to be awaited | ||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will need to make sure the client is logged into as well. Also want to only login when we know we're going do writes so we don't hit session rate limits https://npmx.dev/package/@atproto/lex#user-content-authenticated-client-with-password |
||||||||||||||||||||||||||||||||||||
| await client.put(site.standard.document, document, { | ||||||||||||||||||||||||||||||||||||
| rkey: tid.str, | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+123
to
+130
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, locate and inspect the file in question
git ls-files | grep -E "standard-site-sync\.ts"Repository: npmx-dev/npmx.dev Length of output: 90 🏁 Script executed: # Get the full context around lines 123-130
if [ -f "modules/standard-site-sync.ts" ]; then
wc -l modules/standard-site-sync.ts
echo "=== Lines 110-145 ==="
sed -n '110,145p' modules/standard-site-sync.ts
fiRepository: npmx-dev/npmx.dev Length of output: 933 🏁 Script executed: # Search for TID implementation
rg -t ts "class TID|function TID|fromTime" --context 3Repository: npmx-dev/npmx.dev Length of output: 615 🏁 Script executed: # Check for imports and CLOCK_ID_THREE definition
rg -t ts "CLOCK_ID_THREE|DATE_TO_MICROSECONDS" --context 2Repository: npmx-dev/npmx.dev Length of output: 963 🌐 Web query:
💡 Result:
Sources: Citations:
🌐 Web query:
💡 Result: Lexicon
|
||||||||||||||||||||||||||||||||||||
| const dateInMicroSeconds = new Date(result.output.date).getTime() * DATE_TO_MICROSECONDS | |
| // Clock id(3) needs to be the same everytime to get the same TID from a timestamp | |
| const tid = TID.fromTime(dateInMicroSeconds, CLOCK_ID_THREE) | |
| // client.put is async and needs to be awaited | |
| await client.put(site.standard.document, document, { | |
| rkey: tid.str, | |
| const dateInMicroSeconds = new Date(result.output.date).getTime() * DATE_TO_MICROSECONDS | |
| const clockId = | |
| Number.parseInt(createHash('sha256').update(data.path).digest('hex').slice(0, 2), 16) % 1024 | |
| const tid = TID.fromTime(dateInMicroSeconds, clockId) | |
| // client.put is async and needs to be awaited | |
| await client.put(site.standard.document, document, { | |
| rkey: tid.str, |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import * as v from 'valibot' | ||
| import { CACHE_MAX_AGE_ONE_DAY, BLUESKY_API } from '#shared/utils/constants' | ||
| import { AuthorSchema } from '#shared/schemas/blog' | ||
| import type { Author, ResolvedAuthor } from '#shared/schemas/blog' | ||
|
|
||
| type ProfilesResponse = { | ||
| profiles: Array<{ | ||
| did: string | ||
| handle: string | ||
| displayName?: string | ||
| avatar?: string | ||
| }> | ||
| } | ||
|
|
||
| export default defineCachedEventHandler( | ||
| async event => { | ||
| const query = getQuery(event) | ||
| const authorsParam = query.authors | ||
|
|
||
| if (!authorsParam || typeof authorsParam !== 'string') { | ||
| throw createError({ | ||
| statusCode: 400, | ||
| statusMessage: 'authors query parameter is required (JSON array)', | ||
| }) | ||
| } | ||
|
|
||
| let authors: Author[] | ||
| try { | ||
| const parsed = JSON.parse(authorsParam) | ||
| authors = v.parse(v.array(AuthorSchema), parsed) | ||
| } catch (error) { | ||
| if (error instanceof v.ValiError) { | ||
| throw createError({ | ||
| statusCode: 400, | ||
| statusMessage: `Invalid authors format: ${error.message}`, | ||
| }) | ||
| } | ||
| throw createError({ | ||
| statusCode: 400, | ||
| statusMessage: 'authors must be valid JSON', | ||
| }) | ||
| } | ||
|
|
||
| if (!Array.isArray(authors) || authors.length === 0) { | ||
| return { authors: [] } | ||
| } | ||
|
|
||
| const handles = authors.filter(a => a.blueskyHandle).map(a => a.blueskyHandle as string) | ||
|
|
||
| if (handles.length === 0) { | ||
| return { | ||
| authors: authors.map(author => ({ | ||
| ...author, | ||
| avatar: null, | ||
| profileUrl: null, | ||
| })), | ||
| } | ||
| } | ||
|
|
||
| const response = await $fetch<ProfilesResponse>(`${BLUESKY_API}app.bsky.actor.getProfiles`, { | ||
jonathanyeong marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| query: { actors: handles }, | ||
| }).catch(() => ({ profiles: [] })) | ||
|
|
||
| const avatarMap = new Map<string, string>() | ||
| for (const profile of response.profiles) { | ||
| if (profile.avatar) { | ||
| avatarMap.set(profile.handle, profile.avatar) | ||
| } | ||
| } | ||
|
|
||
| const resolvedAuthors: ResolvedAuthor[] = authors.map(author => ({ | ||
| ...author, | ||
| avatar: author.blueskyHandle ? avatarMap.get(author.blueskyHandle) || null : null, | ||
| profileUrl: author.blueskyHandle ? `https://bsky.app/profile/${author.blueskyHandle}` : null, | ||
| })) | ||
|
|
||
| return { authors: resolvedAuthors } | ||
| }, | ||
| { | ||
| name: 'author-profiles', | ||
| maxAge: CACHE_MAX_AGE_ONE_DAY, | ||
| getKey: event => { | ||
| const { authors } = getQuery(event) | ||
| return `author-profiles:${authors ?? 'npmx.dev'}` | ||
| }, | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }, | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.