add the pds, chat and throwing clients

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-03 13:29:25 +03:00
parent dc9741d8ba
commit 40521e4008
2 changed files with 289 additions and 29 deletions
+181 -15
View File
@@ -13,9 +13,16 @@ jest.mock('jwt-decode', () => ({
},
}))
import {app} from '#/lexicons'
import {CHAT_PROXY_SERVICE} from '#/lib/constants'
import {app, chat, com} from '#/lexicons'
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
import {agentToLexClient} from '../clients'
import {
agentToAppviewClient,
agentToChatClient,
agentToPdsClient,
getUnauthenticatedThrowingClient,
NotAuthenticatedError,
} from '../clients'
import {sessionAccountToSessionData} from '../session-data'
import {
asFetch,
@@ -26,6 +33,7 @@ import {
makeMockFetch,
type MockFetch,
SERVICE,
urlsOf,
} from './mock-fetch'
const PROFILE_BODY = {
@@ -70,7 +78,7 @@ function initFor(mock: MockFetch, nsid: string): RequestInit | undefined {
return call?.[1]
}
describe('agentToLexClient', () => {
describe('agentToAppviewClient', () => {
let fetchMock: MockFetch
beforeEach(() => {
@@ -81,9 +89,9 @@ describe('agentToLexClient', () => {
const {agent: agentA} = setup(fetchMock)
const {agent: agentB} = setup(fetchMock)
const clientA1 = agentToLexClient(agentA)
const clientA2 = agentToLexClient(agentA)
const clientB = agentToLexClient(agentB)
const clientA1 = agentToAppviewClient(agentA)
const clientA2 = agentToAppviewClient(agentA)
const clientB = agentToAppviewClient(agentB)
expect(clientA1).toBeInstanceOf(Client)
expect(clientA1).toBe(clientA2)
@@ -92,20 +100,23 @@ describe('agentToLexClient', () => {
it('passes through the agent did', () => {
const {agent} = setup(fetchMock)
expect(agentToLexClient(agent).did).toBe(DID)
expect(agentToAppviewClient(agent).did).toBe(DID)
})
it('reflects an undefined did on a logged-out agent', () => {
const {agent} = setupPublic(fetchMock)
expect(agentToLexClient(agent).did).toBeUndefined()
expect(agentToAppviewClient(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,
})
const body = await agentToAppviewClient(agent).call(
app.bsky.actor.getProfile,
{
actor: HANDLE,
},
)
expect(body.handle).toBe(HANDLE)
const call = fetchMock.mock.calls.find(c => {
@@ -121,7 +132,7 @@ describe('agentToLexClient', () => {
const {agent} = setup(fetchMock)
agent.configureProxy('did:web:api.bsky.app#bsky_appview')
await agentToLexClient(agent).call(app.bsky.actor.getProfile, {
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, {
actor: HANDLE,
})
@@ -135,7 +146,7 @@ describe('agentToLexClient', () => {
const {agent} = setup(fetchMock)
agent.configureLabelersHeader(['did:plc:labeler'])
await agentToLexClient(agent).call(app.bsky.actor.getProfile, {
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, {
actor: HANDLE,
})
@@ -155,7 +166,7 @@ describe('agentToLexClient', () => {
it('sends the session access token', async () => {
const {agent} = setup(fetchMock)
await agentToLexClient(agent).call(app.bsky.actor.getProfile, {
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, {
actor: HANDLE,
})
@@ -167,7 +178,7 @@ describe('agentToLexClient', () => {
it('falls back to unauthenticated requests once the agent is disposed', async () => {
const {agent} = setup(fetchMock)
const client = agentToLexClient(agent)
const client = agentToAppviewClient(agent)
agent.dispose()
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
@@ -177,3 +188,158 @@ describe('agentToLexClient', () => {
expect(client.did).toBeUndefined()
})
})
describe('agentToPdsClient', () => {
let fetchMock: MockFetch
beforeEach(() => {
fetchMock = makeProfileFetch()
})
it('memoizes one client per agent', () => {
const {agent: agentA} = setup(fetchMock)
const {agent: agentB} = setup(fetchMock)
const clientA1 = agentToPdsClient(agentA)
const clientA2 = agentToPdsClient(agentA)
expect(clientA1).toBeInstanceOf(Client)
expect(clientA1).toBe(clientA2)
expect(clientA1).not.toBe(agentToPdsClient(agentB))
})
it('is a distinct client from the appview client for the same agent', () => {
const {agent} = setup(fetchMock)
expect(agentToPdsClient(agent)).not.toBe(agentToAppviewClient(agent))
})
it('passes through the agent did', () => {
const {agent} = setup(fetchMock)
expect(agentToPdsClient(agent).did).toBe(DID)
})
it('sends the session access token', async () => {
const {agent} = setup(fetchMock)
await agentToPdsClient(agent).call(com.atproto.server.getSession, {})
const init = initFor(fetchMock, 'com.atproto.server.getSession')
expect(new Headers(init?.headers).get('authorization')).toBe(
'Bearer access-jwt',
)
})
it('emits neither the proxy nor the labeler header the agent is configured with', async () => {
/*
* The load-bearing difference from the appview client: this client wraps the
* session manager, below the agent layer that sets both headers, so a
* request reaches the account's PDS instead of being proxied onward.
*/
const {agent} = setup(fetchMock)
agent.configureProxy('did:web:api.bsky.app#bsky_appview')
agent.configureLabelersHeader(['did:plc:labeler'])
await agentToPdsClient(agent).call(com.atproto.server.getSession, {})
const headers = new Headers(
initFor(fetchMock, 'com.atproto.server.getSession')?.headers,
)
expect(headers.get('atproto-proxy')).toBeNull()
expect(headers.get('atproto-accept-labelers')).toBeNull()
})
it('resolves the relative xrpc path against the account host', async () => {
/*
* lex-client hands its fetchHandler an origin-less `/xrpc/<nsid>` path; the
* session manager absolutizes it against dispatchUrl.
*/
const {agent} = setup(fetchMock)
await agentToPdsClient(agent).call(com.atproto.server.getSession, {})
expect(urlsOf(fetchMock)).toContain(
`${SERVICE}/xrpc/com.atproto.server.getSession`,
)
})
})
describe('agentToChatClient', () => {
let fetchMock: MockFetch
beforeEach(() => {
fetchMock = makeProfileFetch()
})
it('memoizes one client per agent, distinct from the pds client', () => {
const {agent} = setup(fetchMock)
const client = agentToChatClient(agent)
expect(client).toBeInstanceOf(Client)
expect(client).toBe(agentToChatClient(agent))
expect(client).not.toBe(agentToPdsClient(agent))
})
it('emits the chat proxy header exactly once, with the session token', async () => {
const {agent} = setup(fetchMock)
/* the stub body fails listConvos output validation; headers are recorded pre-parse */
await agentToChatClient(agent)
.call(chat.bsky.convo.listConvos, {})
.catch(() => {})
const headers = new Headers(
initFor(fetchMock, 'chat.bsky.convo.listConvos')?.headers,
)
/*
* An exact match, not `toContain`: `Headers` comma-joins repeated entries
* for the same name, so a second contributor would show up here.
*/
expect(headers.get('atproto-proxy')).toBe(CHAT_PROXY_SERVICE)
expect(headers.get('authorization')).toBe('Bearer access-jwt')
})
it('does not emit the agent labeler header', async () => {
const {agent} = setup(fetchMock)
agent.configureLabelersHeader(['did:plc:labeler'])
await agentToChatClient(agent)
.call(chat.bsky.convo.listConvos, {})
.catch(() => {})
const headers = new Headers(
initFor(fetchMock, 'chat.bsky.convo.listConvos')?.headers,
)
expect(headers.get('atproto-accept-labelers')).toBeNull()
})
})
describe('getUnauthenticatedThrowingClient', () => {
it('is a stable singleton with no did', () => {
const client = getUnauthenticatedThrowingClient()
expect(client.did).toBeUndefined()
/* identity is stable so it is safe in React Query keys */
expect(getUnauthenticatedThrowingClient()).toBe(client)
})
it('rejects any call with NotAuthenticatedError as the cause, with no fetch', async () => {
/*
* The throwing fetchHandler fires before any network I/O. lex-client wraps a
* fetchHandler throw in an internal error whose `cause` is the original, so
* the NotAuthenticatedError surfaces there.
*/
const fetchMock = makeProfileFetch()
const err = await getUnauthenticatedThrowingClient()
.call(com.atproto.server.getSession, {})
.then(() => undefined)
.catch((e: unknown) => e)
expect((err as Error).cause).toBeInstanceOf(NotAuthenticatedError)
expect(((err as Error).cause as Error).name).toBe('NotAuthenticatedError')
expect(((err as Error).cause as Error).message).toBe(
'Not authenticated: this operation requires an active session',
)
expect(fetchMock).not.toHaveBeenCalled()
})
})
+108 -14
View File
@@ -1,15 +1,15 @@
import {type Client} from '@atproto/lex'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {CHAT_PROXY_SERVICE, 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
* One client per agent, per surface, 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
@@ -17,10 +17,12 @@ import {networkAwareFetch} from './network'
* 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>()
const appviewClients = new WeakMap<BskyAppAgent, Client>()
const pdsClients = new WeakMap<BskyAppAgent, Client>()
const chatClients = new WeakMap<BskyAppAgent, Client>()
/**
* The lex {@link Client} for an agent, memoized per agent.
* The appview {@link Client} for an agent, memoized per agent.
*
* The wrapped handler is `agent.fetchHandler`, NOT
* `agent.sessionManager.fetchHandler`. The agent-level handler is where
@@ -30,8 +32,8 @@ const lexClients = new WeakMap<BskyAppAgent, Client>()
* 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)
export function agentToAppviewClient(agent: BskyAppAgent): Client {
const existing = appviewClients.get(agent)
if (existing) {
return existing
}
@@ -41,10 +43,101 @@ export function agentToLexClient(agent: BskyAppAgent): Client {
},
fetchHandler: (path, init) => agent.fetchHandler(path, init),
})
lexClients.set(agent, client)
appviewClients.set(agent, client)
return client
}
/**
* The account-host {@link Client} for an agent, memoized per agent.
*
* This wraps `agent.sessionManager.fetchHandler`, one layer below
* {@link agentToAppviewClient}. That layer does authorization and refresh-on-401
* and resolves the request against `dispatchUrl` (the account's PDS), but it
* does NOT set `atproto-proxy` or `atproto-accept-labelers`, so requests reach
* the PDS itself rather than being proxied onward. That is the right transport
* for `com.atproto.*` repo/server/identity calls.
*
* No `service` option for the same reason: adding one would reintroduce the
* proxy header this client exists to avoid.
*
* The handler is wrapped in a closure rather than passed by reference because
* `PasswordSessionManager.fetchHandler` reads `this`. Relative paths are
* intentional: lex-client hands its handler an origin-less
* `/xrpc/<nsid>[?query]` path, which the session manager absolutizes against
* `dispatchUrl`.
*/
export function agentToPdsClient(agent: BskyAppAgent): Client {
const existing = pdsClients.get(agent)
if (existing) {
return existing
}
const client = createLexClient({
get did() {
return agent.did
},
fetchHandler: (path, init) => agent.sessionManager.fetchHandler(path, init),
})
pdsClients.set(agent, client)
return client
}
/**
* The chat {@link Client} for an agent, memoized per agent.
*
* Same session-manager transport as {@link agentToPdsClient} - authorization
* and PDS routing, no agent-level proxy or labeler headers - but constructed
* with {@link CHAT_PROXY_SERVICE} as its `service`, so lex-client emits
* `atproto-proxy: <CHAT_PROXY_SERVICE>` on every request and `chat.bsky.*`
* calls are proxied to the chat service.
*/
export function agentToChatClient(agent: BskyAppAgent): Client {
const existing = chatClients.get(agent)
if (existing) {
return existing
}
const client = createLexClient(
{
get did() {
return agent.did
},
fetchHandler: (path, init) =>
agent.sessionManager.fetchHandler(path, init),
},
{service: CHAT_PROXY_SERVICE},
)
chatClients.set(agent, client)
return client
}
/** Thrown when a write/auth-only client is used with no active session. */
export class NotAuthenticatedError extends Error {
constructor(op = 'this operation') {
super(`Not authenticated: ${op} requires an active session`)
this.name = 'NotAuthenticatedError'
}
}
let unauthedClient: Client | undefined
/**
* A {@link Client} that throws {@link NotAuthenticatedError} on any request,
* before any network I/O. It is the logged-out value of the write/auth-only
* hooks (`usePdsClient`/`useChatClient`) so an unauthenticated call fails
* immediately and legibly instead of silently hitting public infrastructure,
* which would answer with an opaque 4xx.
*
* A single module-level instance, so its identity is stable across renders -
* safe to use in React Query keys and as a hook return value.
*/
export function getUnauthenticatedThrowingClient(): Client {
return (unauthedClient ??= createLexClient({
did: undefined,
fetchHandler: () => {
throw new NotAuthenticatedError()
},
}))
}
let publicLexClient: Client | undefined
/**
@@ -52,9 +145,10 @@ let publicLexClient: Client | undefined
* 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.
* {@link agentToAppviewClient}: 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
@@ -63,7 +157,7 @@ let publicLexClient: Client | undefined
* consumer that needs moderation labels on public reads must configure labelers
* itself before issuing the request.
*/
export function getPublicLexClient(): Client {
export function getPublicAppviewClient(): Client {
return (publicLexClient ??= createLexClient({
service: PUBLIC_BSKY_SERVICE,
fetch: networkAwareFetch,