add lex client seam, types/bsky schema helpers, pilot trending migration

This commit is contained in:
Samuel Newman
2026-07-15 19:38:25 +03:00
parent 15e232749a
commit 105e68c12c
7 changed files with 490 additions and 8 deletions
@@ -1,5 +1,5 @@
import {useCallback, useMemo} from 'react'
import {type AppBskyUnspeccedGetTrends, hasMutedWord} from '@atproto/api'
import {hasMutedWord} from '@atproto/api'
import {useQuery} from '@tanstack/react-query'
import {
@@ -10,7 +10,8 @@ import {logger} from '#/logger'
import {getContentLanguages} from '#/state/preferences/languages'
import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
import {useLexClient} from '#/state/session'
import {app} from '#/lexicons'
export const DEFAULT_LIMIT = 5
@@ -32,7 +33,7 @@ export const createGetTrendsQueryKey = (limit?: number) =>
limit === undefined ? ['trends'] : ['trends', {limit}]
export function useGetTrendsQuery(props: QueryProps = {}) {
const agent = useAgent()
const client = useLexClient()
const {data: preferences} = usePreferencesQuery()
const limit = props.limit ?? DEFAULT_LIMIT
const mutedWords = useMemo(() => {
@@ -46,7 +47,8 @@ export function useGetTrendsQuery(props: QueryProps = {}) {
queryKey: createGetTrendsQueryKey(limit),
queryFn: async () => {
const contentLangs = getContentLanguages().join(',')
const {data} = await agent.app.bsky.unspecced.getTrends(
const data = await client.call(
app.bsky.unspecced.getTrends,
{
limit,
},
@@ -63,7 +65,7 @@ export function useGetTrendsQuery(props: QueryProps = {}) {
return data
},
select: useCallback(
(data: AppBskyUnspeccedGetTrends.OutputSchema) => {
(data: app.bsky.unspecced.getTrends.$OutputBody) => {
return {
recId: data.recIdStr,
trends: dedupe(
@@ -0,0 +1,70 @@
import {type AtpAgent} from '@atproto/api'
import {Client} from '@atproto/lex-client'
import {describe, expect, it, jest} from '@jest/globals'
import {app} from '#/lexicons'
import {agentToLexClient} from '../clients'
/**
* Minimal stand-in for the parts of AtpAgent that `agentToLexClient` reads: a
* `did` and a `fetchHandler`. Returned as `AtpAgent` via a cast since we only
* exercise those two members.
*/
function makeFakeAgent(did: string | undefined) {
const fetchHandler = jest.fn(
(_path: string, _init: RequestInit): Promise<Response> =>
Promise.resolve(
new Response(
JSON.stringify({
did: 'did:plc:fake',
handle: 'fake.bsky.social',
}),
{status: 200, headers: {'content-type': 'application/json'}},
),
),
)
const agent = {did, fetchHandler}
return {agent: agent as unknown as AtpAgent, fetchHandler}
}
describe('agentToLexClient', () => {
it('routes client.call through the agent fetchHandler', async () => {
const {agent, fetchHandler} = makeFakeAgent('did:plc:fake')
const client = agentToLexClient(agent)
const result = await client.call(app.bsky.actor.getProfile.main, {
actor: 'fake.bsky.social',
})
expect(fetchHandler).toHaveBeenCalledTimes(1)
const [path] = fetchHandler.mock.calls[0]
expect(path).toContain('/xrpc/app.bsky.actor.getProfile')
expect(path).toContain('actor=fake.bsky.social')
expect(result.handle).toBe('fake.bsky.social')
})
it('passes through the agent did', () => {
const {agent} = makeFakeAgent('did:plc:fake')
const client = agentToLexClient(agent)
expect(client.did).toBe('did:plc:fake')
})
it('reflects an undefined did (unauthenticated agent)', () => {
const {agent} = makeFakeAgent(undefined)
const client = agentToLexClient(agent)
expect(client.did).toBeUndefined()
})
it('memoizes one client per agent', () => {
const {agent: agentA} = makeFakeAgent('did:plc:a')
const {agent: agentB} = makeFakeAgent('did:plc:b')
const clientA1 = agentToLexClient(agentA)
const clientA2 = agentToLexClient(agentA)
const clientB = agentToLexClient(agentB)
expect(clientA1).toBeInstanceOf(Client)
expect(clientA1).toBe(clientA2)
expect(clientA1).not.toBe(clientB)
})
})
+72
View File
@@ -0,0 +1,72 @@
import {type AtpAgent} from '@atproto/api'
import {Client} from '@atproto/lex-client'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
/**
* Stable per-agent cache of lex `Client` instances. We never reuse an
* `AtpAgent` (the session provider disposes the previous one on switch), so a
* `WeakMap` keyed on the agent gives us a client whose identity is stable for
* the lifetime of that agent. This keeps React Query keys and hook consumers
* from churning on every render.
*/
const clientForAgent = new WeakMap<AtpAgent, Client>()
/**
* Wrap a live {@link AtpAgent} as a lex {@link Client}, bridging the old session
* source of truth to the new SDK so features can migrate incrementally.
*
* The client talks to the agent through the minimal lex `Agent` interface
* (`{ did, fetchHandler }`). We deliberately route `fetchHandler` through
* `agent.fetchHandler` (the agent's XRPC dispatch layer) rather than
* `agent.sessionManager.fetchHandler`:
*
* - `agent.fetchHandler` (see @atproto/api `agent.js`) is where the agent
* applies its configured `atproto-proxy` header (set via `configureProxy` to
* the appview) and its `atproto-accept-labelers` header, before delegating to
* `sessionManager.fetchHandler` for authorization + token refresh.
* - `sessionManager.fetchHandler` (`CredentialSession`) only adds the auth
* token and handles refresh - it does NOT proxy or attach labelers. Wrapping
* it directly would silently drop appview proxying and moderation labelers.
*
* Because the wrapped agent already applies proxy + labeler headers, we do NOT
* pass a `service` option to the `Client` (lex-client only sets `atproto-proxy`
* when `service` is provided) and we leave `Client.appLabelers` at its default
* empty set. This avoids double-setting either header.
*
* Results are memoized per-agent so the returned client is referentially stable.
*/
export function agentToLexClient(agent: AtpAgent): Client {
const cached = clientForAgent.get(agent)
if (cached) {
return cached
}
const client = new Client({
get did() {
return agent.did
},
fetchHandler: (path, init) => agent.fetchHandler(path, init),
})
clientForAgent.set(agent, client)
return client
}
/**
* Lazily-constructed unauthenticated client pointed at the public appview. It
* hits {@link PUBLIC_BSKY_SERVICE} directly, mirroring `createPublicAgent`'s
* service URL, so no proxying is required.
*/
let publicClient: Client | undefined
function getPublicLexClient(): Client {
publicClient ??= new Client(PUBLIC_BSKY_SERVICE)
return publicClient
}
/**
* Unauthenticated lex {@link Client} for public appview reads. A process-wide
* singleton, so its identity is stable across renders.
*/
export function usePublicLexClient(): Client {
return getPublicLexClient()
}
+13
View File
@@ -9,6 +9,7 @@ import {
useSyncExternalStore,
} from 'react'
import {type AtpAgent, type AtpSessionEvent} from '@atproto/api'
import {type Client} from '@atproto/lex-client'
import * as persisted from '#/state/persisted'
import {useCloseAllActiveElements} from '#/state/util'
@@ -26,6 +27,7 @@ import {
} from './agent'
import {type Action, getInitialState, reducer, type State} from './reducer'
export {isSignupQueued} from './util'
import {agentToLexClient} from './clients'
import {addSessionDebugLog} from './logging'
export type {SessionAccount} from '#/state/session/types'
@@ -460,3 +462,14 @@ export function useAgent(): AtpAgent {
}
return agent
}
/**
* Authenticated lex {@link Client} wrapping the current session agent. Stable
* per-agent, so it only changes identity when the active account changes.
*
* @see agentToLexClient for how the AtpAgent is bridged to the lex Client.
*/
export function useLexClient(): Client {
const agent = useAgent()
return agentToLexClient(agent)
}