[SDK] Add pds and chat clients and the canonical client hooks (#11350)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import {type Insets, Platform} from 'react-native'
|
import {type Insets, Platform} from 'react-native'
|
||||||
import {type AppBskyActorDefs, BSKY_LABELER_DID} from '@atproto/api'
|
import {type AppBskyActorDefs, BSKY_LABELER_DID} from '@atproto/api'
|
||||||
|
import {type Service} from '@atproto/lex'
|
||||||
|
|
||||||
import {type ProxyHeaderValue} from '#/state/session/agent'
|
import {type ProxyHeaderValue} from '#/state/session/agent'
|
||||||
import {BLUESKY_PROXY_DID, CHAT_PROXY_DID, IS_DEV} from '#/env'
|
import {BLUESKY_PROXY_DID, CHAT_PROXY_DID, IS_DEV} from '#/env'
|
||||||
@@ -247,6 +248,21 @@ export const BLUESKY_PROXY_HEADER = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chat service's proxy target, in the `did#service_id` form a lex client's
|
||||||
|
* `service` option takes. A client constructed with it emits `atproto-proxy:
|
||||||
|
* <this value>` on every request, which is what routes `chat.bsky.*` calls to
|
||||||
|
* the chat service.
|
||||||
|
*
|
||||||
|
* The DID comes from the env-configurable `CHAT_PROXY_DID` (via
|
||||||
|
* `EXPO_PUBLIC_CHAT_PROXY_DID`) rather than a hard-coded constant, so the
|
||||||
|
* target can be retargeted per environment.
|
||||||
|
*
|
||||||
|
* This is the client-level equivalent of {@link DM_SERVICE_HEADERS}, which
|
||||||
|
* carries the same value as a per-call header.
|
||||||
|
*/
|
||||||
|
export const CHAT_PROXY_SERVICE: Service = `${CHAT_PROXY_DID}#bsky_chat`
|
||||||
|
|
||||||
export const DM_SERVICE_HEADERS = {
|
export const DM_SERVICE_HEADERS = {
|
||||||
'atproto-proxy': `${CHAT_PROXY_DID}#bsky_chat`,
|
'atproto-proxy': `${CHAT_PROXY_DID}#bsky_chat`,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {logger} from '#/logger'
|
|||||||
import {getContentLanguages} from '#/state/preferences/languages'
|
import {getContentLanguages} from '#/state/preferences/languages'
|
||||||
import {STALE} from '#/state/queries'
|
import {STALE} from '#/state/queries'
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {useLexClient} from '#/state/session'
|
import {useAppviewClient} from '#/state/session'
|
||||||
import {app} from '#/lexicons'
|
import {app} from '#/lexicons'
|
||||||
|
|
||||||
export const DEFAULT_LIMIT = 5
|
export const DEFAULT_LIMIT = 5
|
||||||
@@ -33,7 +33,7 @@ export const createGetTrendsQueryKey = (limit?: number) =>
|
|||||||
limit === undefined ? ['trends'] : ['trends', {limit}]
|
limit === undefined ? ['trends'] : ['trends', {limit}]
|
||||||
|
|
||||||
export function useGetTrendsQuery(props: QueryProps = {}) {
|
export function useGetTrendsQuery(props: QueryProps = {}) {
|
||||||
const client = useLexClient()
|
const client = useAppviewClient()
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
const limit = props.limit ?? DEFAULT_LIMIT
|
const limit = props.limit ?? DEFAULT_LIMIT
|
||||||
const mutedWords = useMemo(() => {
|
const mutedWords = useMemo(() => {
|
||||||
|
|||||||
@@ -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 {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
|
||||||
import {agentToLexClient} from '../clients'
|
import {
|
||||||
|
agentToAppviewClient,
|
||||||
|
agentToChatClient,
|
||||||
|
agentToPdsClient,
|
||||||
|
getUnauthenticatedThrowingClient,
|
||||||
|
NotAuthenticatedError,
|
||||||
|
} from '../clients'
|
||||||
import {sessionAccountToSessionData} from '../session-data'
|
import {sessionAccountToSessionData} from '../session-data'
|
||||||
import {
|
import {
|
||||||
asFetch,
|
asFetch,
|
||||||
@@ -26,6 +33,7 @@ import {
|
|||||||
makeMockFetch,
|
makeMockFetch,
|
||||||
type MockFetch,
|
type MockFetch,
|
||||||
SERVICE,
|
SERVICE,
|
||||||
|
urlsOf,
|
||||||
} from './mock-fetch'
|
} from './mock-fetch'
|
||||||
|
|
||||||
const PROFILE_BODY = {
|
const PROFILE_BODY = {
|
||||||
@@ -70,7 +78,7 @@ function initFor(mock: MockFetch, nsid: string): RequestInit | undefined {
|
|||||||
return call?.[1]
|
return call?.[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('agentToLexClient', () => {
|
describe('agentToAppviewClient', () => {
|
||||||
let fetchMock: MockFetch
|
let fetchMock: MockFetch
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -81,9 +89,9 @@ describe('agentToLexClient', () => {
|
|||||||
const {agent: agentA} = setup(fetchMock)
|
const {agent: agentA} = setup(fetchMock)
|
||||||
const {agent: agentB} = setup(fetchMock)
|
const {agent: agentB} = setup(fetchMock)
|
||||||
|
|
||||||
const clientA1 = agentToLexClient(agentA)
|
const clientA1 = agentToAppviewClient(agentA)
|
||||||
const clientA2 = agentToLexClient(agentA)
|
const clientA2 = agentToAppviewClient(agentA)
|
||||||
const clientB = agentToLexClient(agentB)
|
const clientB = agentToAppviewClient(agentB)
|
||||||
|
|
||||||
expect(clientA1).toBeInstanceOf(Client)
|
expect(clientA1).toBeInstanceOf(Client)
|
||||||
expect(clientA1).toBe(clientA2)
|
expect(clientA1).toBe(clientA2)
|
||||||
@@ -92,20 +100,23 @@ describe('agentToLexClient', () => {
|
|||||||
|
|
||||||
it('passes through the agent did', () => {
|
it('passes through the agent did', () => {
|
||||||
const {agent} = setup(fetchMock)
|
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', () => {
|
it('reflects an undefined did on a logged-out agent', () => {
|
||||||
const {agent} = setupPublic(fetchMock)
|
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 () => {
|
it('routes client.call through the agent to the network', async () => {
|
||||||
const {agent} = setup(fetchMock)
|
const {agent} = setup(fetchMock)
|
||||||
|
|
||||||
const body = await agentToLexClient(agent).call(app.bsky.actor.getProfile, {
|
const body = await agentToAppviewClient(agent).call(
|
||||||
actor: HANDLE,
|
app.bsky.actor.getProfile,
|
||||||
})
|
{
|
||||||
|
actor: HANDLE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
expect(body.handle).toBe(HANDLE)
|
expect(body.handle).toBe(HANDLE)
|
||||||
const call = fetchMock.mock.calls.find(c => {
|
const call = fetchMock.mock.calls.find(c => {
|
||||||
@@ -121,7 +132,7 @@ describe('agentToLexClient', () => {
|
|||||||
const {agent} = setup(fetchMock)
|
const {agent} = setup(fetchMock)
|
||||||
agent.configureProxy('did:web:api.bsky.app#bsky_appview')
|
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,
|
actor: HANDLE,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -135,7 +146,7 @@ describe('agentToLexClient', () => {
|
|||||||
const {agent} = setup(fetchMock)
|
const {agent} = setup(fetchMock)
|
||||||
agent.configureLabelersHeader(['did:plc:labeler'])
|
agent.configureLabelersHeader(['did:plc:labeler'])
|
||||||
|
|
||||||
await agentToLexClient(agent).call(app.bsky.actor.getProfile, {
|
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, {
|
||||||
actor: HANDLE,
|
actor: HANDLE,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -155,7 +166,7 @@ describe('agentToLexClient', () => {
|
|||||||
it('sends the session access token', async () => {
|
it('sends the session access token', async () => {
|
||||||
const {agent} = setup(fetchMock)
|
const {agent} = setup(fetchMock)
|
||||||
|
|
||||||
await agentToLexClient(agent).call(app.bsky.actor.getProfile, {
|
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, {
|
||||||
actor: HANDLE,
|
actor: HANDLE,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -167,7 +178,7 @@ describe('agentToLexClient', () => {
|
|||||||
|
|
||||||
it('falls back to unauthenticated requests once the agent is disposed', async () => {
|
it('falls back to unauthenticated requests once the agent is disposed', async () => {
|
||||||
const {agent} = setup(fetchMock)
|
const {agent} = setup(fetchMock)
|
||||||
const client = agentToLexClient(agent)
|
const client = agentToAppviewClient(agent)
|
||||||
agent.dispose()
|
agent.dispose()
|
||||||
|
|
||||||
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
|
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
|
||||||
@@ -177,3 +188,158 @@ describe('agentToLexClient', () => {
|
|||||||
expect(client.did).toBeUndefined()
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import {type Client} from '@atproto/lex'
|
||||||
|
import {PasswordSession} from '@atproto/lex-password-session'
|
||||||
|
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
|
||||||
|
import {act, render} from '@testing-library/react-native'
|
||||||
|
|
||||||
|
import {type SessionAccount} from '../types'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The provider pulls the whole app shell in through `#/state/util` and the
|
||||||
|
* account factories. These mocks cut the tree back to the session lifecycle
|
||||||
|
* itself, mirroring provider-abort-test.tsx.
|
||||||
|
*/
|
||||||
|
jest.mock('#/state/persisted', () => {
|
||||||
|
const {
|
||||||
|
defaults,
|
||||||
|
}: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema')
|
||||||
|
return {
|
||||||
|
defaults,
|
||||||
|
get: (key: keyof typeof defaults) => defaults[key],
|
||||||
|
write: () => Promise.resolve(),
|
||||||
|
readLatest: (key: keyof typeof defaults) => defaults[key],
|
||||||
|
onUpdate: () => () => {},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
jest.mock('#/state/util', () => ({useCloseAllActiveElements: () => () => {}}))
|
||||||
|
jest.mock('#/components/dialogs/Context', () => ({
|
||||||
|
useGlobalDialogsControlContext: () => ({signinDialogControl: {open() {}}}),
|
||||||
|
}))
|
||||||
|
jest.mock('#/analytics', () => ({
|
||||||
|
AnalyticsContext: ({children}: {children: React.ReactNode}) => children,
|
||||||
|
useAnalyticsBase: () => ({metric() {}, logger: {debug() {}, error() {}}}),
|
||||||
|
utils: {accountToSessionMetadata: () => ({}), useMeta: () => undefined},
|
||||||
|
}))
|
||||||
|
jest.mock('#/state/shell/onboarding', () => ({
|
||||||
|
useOnboardingDispatch: () => () => {},
|
||||||
|
}))
|
||||||
|
jest.mock('#/ageAssurance/data', () => ({
|
||||||
|
clearAgeAssuranceServerDataForAll: () => {},
|
||||||
|
clearAgeAssuranceServerDataForDid: () => {},
|
||||||
|
}))
|
||||||
|
jest.mock('#/lib/persisted-query-storage', () => ({
|
||||||
|
clearPersistedQueryStorage: () => Promise.resolve(),
|
||||||
|
}))
|
||||||
|
jest.mock('#/lib/notifications/notifications', () => ({
|
||||||
|
unregisterPushToken: () => Promise.resolve(),
|
||||||
|
}))
|
||||||
|
jest.mock('jwt-decode', () => ({
|
||||||
|
jwtDecode: () => ({scope: 'com.atproto.access'}),
|
||||||
|
}))
|
||||||
|
jest.mock('#/state/events', () => ({
|
||||||
|
emitSessionDropped: () => {},
|
||||||
|
emitNetworkConfirmed: () => {},
|
||||||
|
emitNetworkLost: () => {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockLogin = jest.fn<(...args: unknown[]) => Promise<unknown>>()
|
||||||
|
jest.mock('../session-core', () => ({
|
||||||
|
...jest.requireActual<object>('../session-core'),
|
||||||
|
createSessionBundleAndLogin: (...args: unknown[]) => mockLogin(...args),
|
||||||
|
}))
|
||||||
|
jest.mock('../create-account', () => ({
|
||||||
|
createSessionBundleAndCreateAccount: () => new Promise(() => {}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import {
|
||||||
|
Provider,
|
||||||
|
useAppviewClient,
|
||||||
|
useChatClient,
|
||||||
|
useMaybeChatClient,
|
||||||
|
useMaybePdsClient,
|
||||||
|
usePdsClient,
|
||||||
|
useSessionApi,
|
||||||
|
} from '#/state/session'
|
||||||
|
import {type SessionApiContext} from '#/state/session/types'
|
||||||
|
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
|
||||||
|
import {
|
||||||
|
agentToAppviewClient,
|
||||||
|
agentToChatClient,
|
||||||
|
agentToPdsClient,
|
||||||
|
getUnauthenticatedThrowingClient,
|
||||||
|
} from '../clients'
|
||||||
|
import {type SessionBundle} from '../session-core'
|
||||||
|
import {sessionAccountToSessionData} from '../session-data'
|
||||||
|
import {asFetch, makeAccount, makeMockFetch} from './mock-fetch'
|
||||||
|
|
||||||
|
type Clients = {
|
||||||
|
appview: Client
|
||||||
|
pds: Client
|
||||||
|
chat: Client
|
||||||
|
maybePds: Client | null
|
||||||
|
maybeChat: Client | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a bundle whose agent is a real `BskyAppAgent` over a real
|
||||||
|
* `PasswordSession`, since the client builders derive from the agent. Only the
|
||||||
|
* fields the provider reads are populated.
|
||||||
|
*/
|
||||||
|
function makeBundle(account: SessionAccount): SessionBundle {
|
||||||
|
const fetchMock = makeMockFetch()
|
||||||
|
const session = new PasswordSession(sessionAccountToSessionData(account), {
|
||||||
|
fetch: asFetch(fetchMock),
|
||||||
|
})
|
||||||
|
const manager = new PasswordSessionManager(session, {
|
||||||
|
service: account.service,
|
||||||
|
})
|
||||||
|
manager.setFetch(asFetch(fetchMock))
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
agent: new BskyAppAgent(manager),
|
||||||
|
service: new URL(account.service),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the provider and hand back the client hooks' current values. */
|
||||||
|
function renderClients(): {api: SessionApiContext; clients: () => Clients} {
|
||||||
|
let api!: SessionApiContext
|
||||||
|
let clients!: Clients
|
||||||
|
function Probe() {
|
||||||
|
api = useSessionApi()
|
||||||
|
clients = {
|
||||||
|
appview: useAppviewClient(),
|
||||||
|
pds: usePdsClient(),
|
||||||
|
chat: useChatClient(),
|
||||||
|
maybePds: useMaybePdsClient(),
|
||||||
|
maybeChat: useMaybeChatClient(),
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
render(
|
||||||
|
<Provider>
|
||||||
|
<Probe />
|
||||||
|
</Provider>,
|
||||||
|
)
|
||||||
|
return {api, clients: () => clients}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockLogin.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('client hooks while logged out', () => {
|
||||||
|
it('serves the public agent for appview reads', () => {
|
||||||
|
const {clients} = renderClients()
|
||||||
|
expect(clients().appview).toBeDefined()
|
||||||
|
/* the logged-out bundle's agent IS the public agent, so no separate branch */
|
||||||
|
expect(clients().appview.did).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serves the throwing client for the write surfaces', () => {
|
||||||
|
const {clients} = renderClients()
|
||||||
|
const throwing = getUnauthenticatedThrowingClient()
|
||||||
|
expect(clients().pds).toBe(throwing)
|
||||||
|
expect(clients().chat).toBe(throwing)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serves null from the maybe variants', () => {
|
||||||
|
const {clients} = renderClients()
|
||||||
|
expect(clients().maybePds).toBeNull()
|
||||||
|
expect(clients().maybeChat).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('client hooks with a session', () => {
|
||||||
|
it('derives every surface from the session bundle agent', async () => {
|
||||||
|
const account = makeAccount()
|
||||||
|
const bundle = makeBundle(account)
|
||||||
|
const {api, clients} = renderClients()
|
||||||
|
|
||||||
|
mockLogin.mockResolvedValueOnce({bundle, account})
|
||||||
|
await act(async () => {
|
||||||
|
await api.login({} as never, 'LoginForm')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(clients().appview).toBe(agentToAppviewClient(bundle.agent))
|
||||||
|
expect(clients().pds).toBe(agentToPdsClient(bundle.agent))
|
||||||
|
expect(clients().chat).toBe(agentToChatClient(bundle.agent))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serves the same clients from the maybe variants', async () => {
|
||||||
|
const account = makeAccount()
|
||||||
|
const bundle = makeBundle(account)
|
||||||
|
const {api, clients} = renderClients()
|
||||||
|
|
||||||
|
mockLogin.mockResolvedValueOnce({bundle, account})
|
||||||
|
await act(async () => {
|
||||||
|
await api.login({} as never, 'LoginForm')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(clients().maybePds).toBe(clients().pds)
|
||||||
|
expect(clients().maybeChat).toBe(clients().chat)
|
||||||
|
})
|
||||||
|
})
|
||||||
+108
-14
@@ -1,15 +1,15 @@
|
|||||||
import {type Client} from '@atproto/lex'
|
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 {createLexClient} from '#/lib/lexClient'
|
||||||
import {type BskyAppAgent} from './bridge-agent'
|
import {type BskyAppAgent} from './bridge-agent'
|
||||||
import {networkAwareFetch} from './network'
|
import {networkAwareFetch} from './network'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* One client per agent, so that repeated reads for the same agent return the
|
* One client per agent, per surface, so that repeated reads for the same agent
|
||||||
* same instance. Client identity is observable: a lex `Client` is passed to
|
* return the same instance. Client identity is observable: a lex `Client` is
|
||||||
* React Query `queryFn`s and read from render paths, so a freshly allocated
|
* passed to React Query `queryFn`s and read from render paths, so a freshly
|
||||||
* client on every read would break any dependency array or reference
|
* allocated client on every read would break any dependency array or reference
|
||||||
* comparison built on top of it.
|
* comparison built on top of it.
|
||||||
*
|
*
|
||||||
* Keying on the agent also ties client lifetime to agent lifetime. A disposed
|
* 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
|
* 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.
|
* 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
|
* The wrapped handler is `agent.fetchHandler`, NOT
|
||||||
* `agent.sessionManager.fetchHandler`. The agent-level handler is where
|
* `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
|
* deliberately built with neither a `service` option nor labelers - setting
|
||||||
* either here would emit them a second time.
|
* either here would emit them a second time.
|
||||||
*/
|
*/
|
||||||
export function agentToLexClient(agent: BskyAppAgent): Client {
|
export function agentToAppviewClient(agent: BskyAppAgent): Client {
|
||||||
const existing = lexClients.get(agent)
|
const existing = appviewClients.get(agent)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
@@ -41,10 +43,101 @@ export function agentToLexClient(agent: BskyAppAgent): Client {
|
|||||||
},
|
},
|
||||||
fetchHandler: (path, init) => agent.fetchHandler(path, init),
|
fetchHandler: (path, init) => agent.fetchHandler(path, init),
|
||||||
})
|
})
|
||||||
lexClients.set(agent, client)
|
appviewClients.set(agent, client)
|
||||||
return 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
|
let publicLexClient: Client | undefined
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,9 +145,10 @@ let publicLexClient: Client | undefined
|
|||||||
* appview.
|
* appview.
|
||||||
*
|
*
|
||||||
* A single module-level instance for the same identity-stability reason as
|
* 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
|
* {@link agentToAppviewClient}: there is no session to scope it to, so it lives
|
||||||
* the lifetime of the process. Requests go through {@link networkAwareFetch} so
|
* for the lifetime of the process. Requests go through
|
||||||
* public reads feed the app's reachability signal like authenticated ones do.
|
* {@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
|
* Unlike the public agent it parallels, this client sends neither
|
||||||
* `atproto-proxy` nor `atproto-accept-labelers`. `createPublicAgent` configures
|
* `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
|
* consumer that needs moderation labels on public reads must configure labelers
|
||||||
* itself before issuing the request.
|
* itself before issuing the request.
|
||||||
*/
|
*/
|
||||||
export function getPublicLexClient(): Client {
|
export function getPublicAppviewClient(): Client {
|
||||||
return (publicLexClient ??= createLexClient({
|
return (publicLexClient ??= createLexClient({
|
||||||
service: PUBLIC_BSKY_SERVICE,
|
service: PUBLIC_BSKY_SERVICE,
|
||||||
fetch: networkAwareFetch,
|
fetch: networkAwareFetch,
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
|||||||
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
|
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
|
||||||
import {IS_WEB} from '#/env'
|
import {IS_WEB} from '#/env'
|
||||||
import {emitSessionDropped} from '../events'
|
import {emitSessionDropped} from '../events'
|
||||||
import {agentToLexClient, getPublicLexClient} from './clients'
|
import {
|
||||||
|
agentToAppviewClient,
|
||||||
|
agentToChatClient,
|
||||||
|
agentToPdsClient,
|
||||||
|
getPublicAppviewClient,
|
||||||
|
getUnauthenticatedThrowingClient,
|
||||||
|
} from './clients'
|
||||||
import {createSessionBundleAndCreateAccount} from './create-account'
|
import {createSessionBundleAndCreateAccount} from './create-account'
|
||||||
import {pickExpiryRescueCandidate} from './expiry-rescue'
|
import {pickExpiryRescueCandidate} from './expiry-rescue'
|
||||||
import {type Action, getInitialState, reducer, type State} from './reducer'
|
import {type Action, getInitialState, reducer, type State} from './reducer'
|
||||||
@@ -711,20 +717,70 @@ export function useAgent(): AtpAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The lex client for the active session, or for the public agent when logged
|
* Client for appview reads.
|
||||||
* out.
|
*
|
||||||
|
* When logged out the bundle's agent is the public agent built by
|
||||||
|
* `createPublicAgent`, which is configured with the appview proxy and dispatches
|
||||||
|
* unauthenticated, so the logged-out fallback is the agent itself - there is no
|
||||||
|
* separate public branch here.
|
||||||
*/
|
*/
|
||||||
export function useLexClient(): Client {
|
export function useAppviewClient(): Client {
|
||||||
const bundle = useContext(BundleContext)
|
const bundle = useContext(BundleContext)
|
||||||
if (!bundle) {
|
if (!bundle) {
|
||||||
throw Error('useLexClient() must be below <SessionProvider>.')
|
throw Error('useAppviewClient() must be below <SessionProvider>.')
|
||||||
}
|
}
|
||||||
return agentToLexClient(bundle.agent)
|
return agentToAppviewClient(bundle.agent)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The unauthenticated lex client for public reads.
|
* Client for account-host requests. It shares the active session's auth
|
||||||
|
* lifecycle but sets no proxy or labeler headers, so calls target the PDS.
|
||||||
|
* Logged out, calls throw `NotAuthenticatedError` before network I/O. Use
|
||||||
|
* {@link useMaybePdsClient} when the caller must branch on authentication.
|
||||||
*/
|
*/
|
||||||
export function usePublicLexClient(): Client {
|
export function usePdsClient(): Client {
|
||||||
return getPublicLexClient()
|
const bundle = useContext(BundleContext)
|
||||||
|
if (!bundle) {
|
||||||
|
throw Error('usePdsClient() must be below <SessionProvider>.')
|
||||||
|
}
|
||||||
|
return bundle.session
|
||||||
|
? agentToPdsClient(bundle.agent)
|
||||||
|
: getUnauthenticatedThrowingClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client for `chat.bsky.*` calls. Logged-out calls throw
|
||||||
|
* `NotAuthenticatedError`; use {@link useMaybeChatClient} to branch on auth.
|
||||||
|
*/
|
||||||
|
export function useChatClient(): Client {
|
||||||
|
const bundle = useContext(BundleContext)
|
||||||
|
if (!bundle) {
|
||||||
|
throw Error('useChatClient() must be below <SessionProvider>.')
|
||||||
|
}
|
||||||
|
return bundle.session
|
||||||
|
? agentToChatClient(bundle.agent)
|
||||||
|
: getUnauthenticatedThrowingClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account-host client for the active session, or `null` when logged out.
|
||||||
|
*/
|
||||||
|
export function useMaybePdsClient(): Client | null {
|
||||||
|
const bundle = useContext(BundleContext)
|
||||||
|
return bundle?.session ? agentToPdsClient(bundle.agent) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat client for the active session, or `null` when logged out.
|
||||||
|
*/
|
||||||
|
export function useMaybeChatClient(): Client | null {
|
||||||
|
const bundle = useContext(BundleContext)
|
||||||
|
return bundle?.session ? agentToChatClient(bundle.agent) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unauthenticated client for public appview reads.
|
||||||
|
*/
|
||||||
|
export function usePublicAppviewClient(): Client {
|
||||||
|
return getPublicAppviewClient()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user