[SDK] Add lex client seam and typed xrpc error matching (#11348)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-13 22:26:13 +03:00
committed by GitHub
parent 532681d5c0
commit 837771a311
8 changed files with 467 additions and 35 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(
+179
View File
@@ -0,0 +1,179 @@
import {Client} from '@atproto/lex'
import {PasswordSession} from '@atproto/lex-password-session'
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
jest.mock('#/state/events', () => ({
emitNetworkConfirmed: jest.fn(),
emitNetworkLost: jest.fn(),
}))
jest.mock('jwt-decode', () => ({
jwtDecode() {
return {scope: 'com.atproto.access'}
},
}))
import {app} from '#/lexicons'
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
import {agentToLexClient} from '../clients'
import {sessionAccountToSessionData} from '../session-data'
import {
asFetch,
DID,
HANDLE,
json,
makeAccount,
makeMockFetch,
type MockFetch,
SERVICE,
} from './mock-fetch'
const PROFILE_BODY = {
did: DID,
handle: HANDLE,
}
/** A mock fetch that answers `getProfile` and records every request. */
function makeProfileFetch(): MockFetch {
return makeMockFetch({
'app.bsky.actor.getProfile': () => json(PROFILE_BODY),
})
}
/** An authenticated agent whose whole network path is the mock fetch. */
function setup(fetchMock: MockFetch = makeProfileFetch()) {
const account = makeAccount()
const inner = new PasswordSession(sessionAccountToSessionData(account), {
fetch: asFetch(fetchMock),
})
const manager = new PasswordSessionManager(inner, {
service: account.service,
})
manager.setFetch(asFetch(fetchMock))
const agent = new BskyAppAgent(manager)
return {agent, fetchMock}
}
/** A logged-out agent whose whole network path is the mock fetch. */
function setupPublic(fetchMock: MockFetch = makeProfileFetch()) {
const manager = new PasswordSessionManager(null, {service: SERVICE})
manager.setFetch(asFetch(fetchMock))
return {agent: new BskyAppAgent(manager), fetchMock}
}
/** The `init` a mock fetch was called with for a given nsid. */
function initFor(mock: MockFetch, nsid: string): RequestInit | undefined {
const call = mock.mock.calls.find(c => {
const url = c[0] instanceof URL ? c[0].href : String(c[0])
return url.includes(`/xrpc/${nsid}`)
})
return call?.[1]
}
describe('agentToLexClient', () => {
let fetchMock: MockFetch
beforeEach(() => {
fetchMock = makeProfileFetch()
})
it('memoizes one client per agent', () => {
const {agent: agentA} = setup(fetchMock)
const {agent: agentB} = setup(fetchMock)
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)
})
it('passes through the agent did', () => {
const {agent} = setup(fetchMock)
expect(agentToLexClient(agent).did).toBe(DID)
})
it('reflects an undefined did on a logged-out agent', () => {
const {agent} = setupPublic(fetchMock)
expect(agentToLexClient(agent).did).toBeUndefined()
})
it('routes client.call through the agent to the network', async () => {
const {agent} = setup(fetchMock)
const body = await agentToLexClient(agent).call(app.bsky.actor.getProfile, {
actor: HANDLE,
})
expect(body.handle).toBe(HANDLE)
const call = fetchMock.mock.calls.find(c => {
const url = c[0] instanceof URL ? c[0].href : String(c[0])
return url.includes('/xrpc/app.bsky.actor.getProfile')
})
expect(call).toBeDefined()
const url = call![0] instanceof URL ? call![0].href : String(call![0])
expect(url).toContain(`actor=${HANDLE}`)
})
it('emits the agent proxy header', async () => {
const {agent} = setup(fetchMock)
agent.configureProxy('did:web:api.bsky.app#bsky_appview')
await agentToLexClient(agent).call(app.bsky.actor.getProfile, {
actor: HANDLE,
})
const init = initFor(fetchMock, 'app.bsky.actor.getProfile')
expect(new Headers(init?.headers).get('atproto-proxy')).toBe(
'did:web:api.bsky.app#bsky_appview',
)
})
it('emits the agent labeler header exactly once', async () => {
const {agent} = setup(fetchMock)
agent.configureLabelersHeader(['did:plc:labeler'])
await agentToLexClient(agent).call(app.bsky.actor.getProfile, {
actor: HANDLE,
})
const init = initFor(fetchMock, 'app.bsky.actor.getProfile')
const labelers = new Headers(init?.headers).get('atproto-accept-labelers')
expect(labelers).toContain('did:plc:labeler')
/*
* The client contributes no labelers of its own, so the agent's single
* entry must not be duplicated.
*/
const entries = labelers!
.split(',')
.filter(l => l.includes('did:plc:labeler'))
expect(entries).toHaveLength(1)
})
it('sends the session access token', async () => {
const {agent} = setup(fetchMock)
await agentToLexClient(agent).call(app.bsky.actor.getProfile, {
actor: HANDLE,
})
const init = initFor(fetchMock, 'app.bsky.actor.getProfile')
expect(new Headers(init?.headers).get('authorization')).toBe(
'Bearer access-jwt',
)
})
it('falls back to unauthenticated requests once the agent is disposed', async () => {
const {agent} = setup(fetchMock)
const client = agentToLexClient(agent)
agent.dispose()
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
const init = initFor(fetchMock, 'app.bsky.actor.getProfile')
expect(new Headers(init?.headers).has('authorization')).toBe(false)
expect(client.did).toBeUndefined()
})
})
+71
View File
@@ -0,0 +1,71 @@
import {type Client} from '@atproto/lex'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {createLexClient} from '#/lib/lexClient'
import {type BskyAppAgent} from './bridge-agent'
import {networkAwareFetch} from './network'
/*
* One client per agent, so that repeated reads for the same agent return the
* same instance. Client identity is observable: a lex `Client` is passed to
* React Query `queryFn`s and read from render paths, so a freshly allocated
* client on every read would break any dependency array or reference
* comparison built on top of it.
*
* Keying on the agent also ties client lifetime to agent lifetime. A disposed
* agent's `fetchHandler` falls back to unauthenticated fetch, and session
* rotation builds a new agent rather than mutating the old one, so a client
* derived from a stale agent becomes unreachable exactly when its agent does.
*/
const lexClients = new WeakMap<BskyAppAgent, Client>()
/**
* The lex {@link Client} for an agent, memoized per agent.
*
* The wrapped handler is `agent.fetchHandler`, NOT
* `agent.sessionManager.fetchHandler`. The agent-level handler is where
* `atproto-proxy` and `atproto-accept-labelers` are set before the request is
* passed down to the session manager, which only adds authorization and PDS
* routing. Because the agent already emits both headers, the client is
* deliberately built with neither a `service` option nor labelers - setting
* either here would emit them a second time.
*/
export function agentToLexClient(agent: BskyAppAgent): Client {
const existing = lexClients.get(agent)
if (existing) {
return existing
}
const client = createLexClient({
get did() {
return agent.did
},
fetchHandler: (path, init) => agent.fetchHandler(path, init),
})
lexClients.set(agent, client)
return client
}
let publicLexClient: Client | undefined
/**
* The unauthenticated {@link Client} for public reads, pointed at the public
* appview.
*
* A single module-level instance for the same identity-stability reason as
* {@link agentToLexClient}: there is no session to scope it to, so it lives for
* the lifetime of the process. Requests go through {@link networkAwareFetch} so
* public reads feed the app's reachability signal like authenticated ones do.
*
* Unlike the public agent it parallels, this client sends neither
* `atproto-proxy` nor `atproto-accept-labelers`. `createPublicAgent` configures
* the app labeler and the proxy header, so a logged-out appview *agent* read
* does carry labelers while the same read through this client does not. A
* consumer that needs moderation labels on public reads must configure labelers
* itself before issuing the request.
*/
export function getPublicLexClient(): Client {
return (publicLexClient ??= createLexClient({
service: PUBLIC_BSKY_SERVICE,
fetch: networkAwareFetch,
}))
}
+21
View File
@@ -10,6 +10,7 @@ import {
useSyncExternalStore,
} from 'react'
import {type AtpAgent} from '@atproto/api'
import {type Client} from '@atproto/lex'
import {type SessionData} from '@atproto/lex-password-session'
import * as persisted from '#/state/persisted'
@@ -18,6 +19,7 @@ import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
import {IS_WEB} from '#/env'
import {emitSessionDropped} from '../events'
import {agentToLexClient, getPublicLexClient} from './clients'
import {createSessionBundleAndCreateAccount} from './create-account'
import {pickExpiryRescueCandidate} from './expiry-rescue'
import {type Action, getInitialState, reducer, type State} from './reducer'
@@ -707,3 +709,22 @@ export function useAgent(): AtpAgent {
}
return bundle.agent
}
/**
* The lex client for the active session, or for the public agent when logged
* out.
*/
export function useLexClient(): Client {
const bundle = useContext(BundleContext)
if (!bundle) {
throw Error('useLexClient() must be below <SessionProvider>.')
}
return agentToLexClient(bundle.agent)
}
/**
* The unauthenticated lex client for public reads.
*/
export function usePublicLexClient(): Client {
return getPublicLexClient()
}