feat: add read-only bird boundary
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
import { TwitterClient } from '@yuta/bird'
|
||||||
|
import type { BirdReader } from './post-service'
|
||||||
|
|
||||||
|
let client: TwitterClient | undefined
|
||||||
|
|
||||||
|
export function getBirdReader(): BirdReader {
|
||||||
|
client ??= new TwitterClient({
|
||||||
|
relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL,
|
||||||
|
profileName: process.env.BIRD_PROFILE_NAME,
|
||||||
|
timeoutMs: 20_000,
|
||||||
|
})
|
||||||
|
return client
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { type BirdReader, loadUserPage, searchPage } from './post-service'
|
||||||
|
|
||||||
|
const reader = (): BirdReader => ({
|
||||||
|
getUserIdByUsername: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ success: true, userId: '42' }),
|
||||||
|
getUserTweetsPaged: vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
tweets: [
|
||||||
|
{ id: '1', text: 'hello', author: { username: 'yuta', name: 'Yuta' } },
|
||||||
|
],
|
||||||
|
nextCursor: 'user-next',
|
||||||
|
}),
|
||||||
|
getAllSearchResults: vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
tweets: [],
|
||||||
|
nextCursor: 'search-next',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loadUserPage', () => {
|
||||||
|
it('resolves a handle and fetches exactly one page', async () => {
|
||||||
|
const client = reader()
|
||||||
|
const result = await loadUserPage(client, {
|
||||||
|
target: 'https://x.com/yuta',
|
||||||
|
cursor: 'cursor-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(client.getUserIdByUsername).toHaveBeenCalledWith('yuta')
|
||||||
|
expect(client.getUserTweetsPaged).toHaveBeenCalledWith('42', 20, {
|
||||||
|
cursor: 'cursor-1',
|
||||||
|
maxPages: 1,
|
||||||
|
pageDelayMs: 0,
|
||||||
|
})
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
page: { nextCursor: 'user-next' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a safe not-found error', async () => {
|
||||||
|
const client = reader()
|
||||||
|
vi.mocked(client.getUserIdByUsername).mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
error: 'User not found: private relay detail',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await loadUserPage(client, { target: 'missing' })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'user-not-found',
|
||||||
|
message: 'ユーザーが見つかりませんでした。',
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('searchPage', () => {
|
||||||
|
it('forwards Top and appends follows once', async () => {
|
||||||
|
const client = reader()
|
||||||
|
await searchPage(client, {
|
||||||
|
query: 'AI lang:ja',
|
||||||
|
product: 'Top',
|
||||||
|
following: true,
|
||||||
|
cursor: 'cursor-2',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(client.getAllSearchResults).toHaveBeenCalledWith(
|
||||||
|
'AI lang:ja filter:follows',
|
||||||
|
{ product: 'Top', cursor: 'cursor-2', maxPages: 1 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes raw response data from posts and quoted posts', async () => {
|
||||||
|
const client = reader()
|
||||||
|
vi.mocked(client.getAllSearchResults).mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
tweets: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
text: 'hello',
|
||||||
|
author: { username: 'yuta', name: 'Yuta' },
|
||||||
|
quotedTweet: {
|
||||||
|
id: '2',
|
||||||
|
text: 'quoted',
|
||||||
|
author: { username: 'bird', name: 'Bird' },
|
||||||
|
_raw: { rest_id: 'private-quoted-detail' },
|
||||||
|
},
|
||||||
|
_raw: { rest_id: 'private-detail' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await searchPage(client, {
|
||||||
|
query: 'AI',
|
||||||
|
product: 'Latest',
|
||||||
|
following: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
page: {
|
||||||
|
nextCursor: undefined,
|
||||||
|
tweets: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
text: 'hello',
|
||||||
|
author: { username: 'yuta', name: 'Yuta' },
|
||||||
|
quotedTweet: {
|
||||||
|
id: '2',
|
||||||
|
text: 'quoted',
|
||||||
|
author: { username: 'bird', name: 'Bird' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { SearchProduct, SearchResult, TweetData } from '@yuta/bird'
|
||||||
|
import { buildSearchQuery, InputError, normalizeUserTarget } from './inputs'
|
||||||
|
import type {
|
||||||
|
LoadError,
|
||||||
|
LoadResult,
|
||||||
|
Post,
|
||||||
|
SearchPageInput,
|
||||||
|
UserPageInput,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
type UserLookupResult = {
|
||||||
|
success: boolean
|
||||||
|
userId?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BirdReader {
|
||||||
|
getUserIdByUsername(username: string): Promise<UserLookupResult>
|
||||||
|
getUserTweetsPaged(
|
||||||
|
userId: string,
|
||||||
|
limit: number,
|
||||||
|
options: {
|
||||||
|
cursor?: string
|
||||||
|
maxPages: number
|
||||||
|
pageDelayMs: number
|
||||||
|
},
|
||||||
|
): Promise<SearchResult>
|
||||||
|
getAllSearchResults(
|
||||||
|
query: string,
|
||||||
|
options: {
|
||||||
|
product: SearchProduct
|
||||||
|
cursor?: string
|
||||||
|
maxPages: number
|
||||||
|
},
|
||||||
|
): Promise<SearchResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
const failure = (
|
||||||
|
code: LoadError['code'],
|
||||||
|
message: string,
|
||||||
|
retryable: boolean,
|
||||||
|
): LoadResult => ({ ok: false, error: { code, message, retryable } })
|
||||||
|
|
||||||
|
function upstreamFailure(message = ''): LoadResult {
|
||||||
|
const lower = message.toLowerCase()
|
||||||
|
if (lower.includes('timeout') || lower.includes('aborted')) {
|
||||||
|
return failure('timeout', '取得がタイムアウトしました。', true)
|
||||||
|
}
|
||||||
|
if (lower.includes('not found')) {
|
||||||
|
return failure('user-not-found', 'ユーザーが見つかりませんでした。', false)
|
||||||
|
}
|
||||||
|
if (lower.includes('suspended') || lower.includes('protected')) {
|
||||||
|
return failure(
|
||||||
|
'user-unavailable',
|
||||||
|
'このユーザーの投稿は取得できません。',
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return failure('upstream', 'X から投稿を取得できませんでした。', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicPost(tweet: TweetData): Post {
|
||||||
|
const post = { ...tweet }
|
||||||
|
delete post._raw
|
||||||
|
if (post.quotedTweet) {
|
||||||
|
post.quotedTweet = publicPost(post.quotedTweet)
|
||||||
|
}
|
||||||
|
return post
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultPage(result: SearchResult): LoadResult {
|
||||||
|
return result.success
|
||||||
|
? {
|
||||||
|
ok: true,
|
||||||
|
page: {
|
||||||
|
tweets: result.tweets.map(publicPost),
|
||||||
|
nextCursor: result.nextCursor,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: upstreamFailure(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUserPage(
|
||||||
|
reader: BirdReader,
|
||||||
|
input: UserPageInput,
|
||||||
|
): Promise<LoadResult> {
|
||||||
|
try {
|
||||||
|
const handle = normalizeUserTarget(input.target)
|
||||||
|
const user = await reader.getUserIdByUsername(handle)
|
||||||
|
if (!user.success || !user.userId) {
|
||||||
|
return upstreamFailure(user.error)
|
||||||
|
}
|
||||||
|
return resultPage(
|
||||||
|
await reader.getUserTweetsPaged(user.userId, 20, {
|
||||||
|
cursor: input.cursor,
|
||||||
|
maxPages: 1,
|
||||||
|
pageDelayMs: 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return failure('timeout', '取得がタイムアウトしました。', true)
|
||||||
|
}
|
||||||
|
if (error instanceof InputError) {
|
||||||
|
return failure('invalid-input', error.message, false)
|
||||||
|
}
|
||||||
|
return upstreamFailure(error instanceof Error ? error.message : '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchPage(
|
||||||
|
reader: BirdReader,
|
||||||
|
input: SearchPageInput,
|
||||||
|
): Promise<LoadResult> {
|
||||||
|
try {
|
||||||
|
return resultPage(
|
||||||
|
await reader.getAllSearchResults(
|
||||||
|
buildSearchQuery(input.query, input.following),
|
||||||
|
{
|
||||||
|
product: input.product,
|
||||||
|
cursor: input.cursor,
|
||||||
|
maxPages: 1,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return failure('timeout', '取得がタイムアウトしました。', true)
|
||||||
|
}
|
||||||
|
if (error instanceof InputError) {
|
||||||
|
return failure('invalid-input', error.message, false)
|
||||||
|
}
|
||||||
|
return upstreamFailure(error instanceof Error ? error.message : '')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
|
import { searchPageInputSchema, userPageInputSchema } from './inputs'
|
||||||
|
import { loadUserPage, searchPage } from './post-service'
|
||||||
|
import type { LoadResult } from './types'
|
||||||
|
|
||||||
|
const configFailure = (): LoadResult => ({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'relay-config',
|
||||||
|
message: 'TWITTER_RELAY_BASE_URL を設定してください。',
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function reader() {
|
||||||
|
const { getBirdReader } = await import('./bird-client.server')
|
||||||
|
return getBirdReader()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadUserPosts = createServerFn({ method: 'GET' })
|
||||||
|
.validator(userPageInputSchema)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
try {
|
||||||
|
return await loadUserPage(await reader(), data)
|
||||||
|
} catch {
|
||||||
|
return configFailure()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const searchPosts = createServerFn({ method: 'GET' })
|
||||||
|
.validator(searchPageInputSchema)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
try {
|
||||||
|
return await searchPage(await reader(), data)
|
||||||
|
} catch {
|
||||||
|
return configFailure()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { SearchProduct, TweetData } from '@yuta/bird'
|
import type { SearchProduct, TweetData } from '@yuta/bird'
|
||||||
|
|
||||||
export type Post = TweetData
|
export type Post = Omit<TweetData, '_raw' | 'quotedTweet'> & {
|
||||||
|
quotedTweet?: Post
|
||||||
|
}
|
||||||
|
|
||||||
export type PostPage = {
|
export type PostPage = {
|
||||||
tweets: Post[]
|
tweets: Post[]
|
||||||
|
|||||||
Reference in New Issue
Block a user