add the lex client seam over the bridge agent
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
type Agent,
|
||||||
|
type AgentOptions,
|
||||||
|
Client,
|
||||||
|
type ClientOptions,
|
||||||
|
} from '@atproto/lex'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-standard factory for lex {@link Client}s. Use this instead of `new
|
||||||
|
* Client(...)` so every client shares the same lenient response processing.
|
||||||
|
*
|
||||||
|
* lex-client defaults to strict Lex processing, which rejects responses
|
||||||
|
* containing the LEGACY blob reference format (objects with `cid` and
|
||||||
|
* `mimeType` properties instead of `$type: 'blob'`). Older records on the
|
||||||
|
* network still carry these, and rejecting them would drop records the app
|
||||||
|
* currently renders. Lenient mode also relaxes datetime format checks (e.g.
|
||||||
|
* missing timezones) and blob MIME/size constraints. `Client.configure` only
|
||||||
|
* accepts `appLabelers` globally, so the option is defaulted here, per
|
||||||
|
* constructed client.
|
||||||
|
*/
|
||||||
|
export function createLexClient(
|
||||||
|
agent: Agent | AgentOptions,
|
||||||
|
options?: ClientOptions,
|
||||||
|
): Client {
|
||||||
|
return new Client(agent, {strictResponseProcessing: false, ...options})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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.
|
||||||
|
*/
|
||||||
|
export function getPublicLexClient(): Client {
|
||||||
|
return (publicLexClient ??= createLexClient({
|
||||||
|
service: PUBLIC_BSKY_SERVICE,
|
||||||
|
fetch: networkAwareFetch,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {type AtpAgent} from '@atproto/api'
|
import {type AtpAgent} from '@atproto/api'
|
||||||
|
import {type Client} from '@atproto/lex'
|
||||||
import {type SessionData} from '@atproto/lex-password-session'
|
import {type SessionData} from '@atproto/lex-password-session'
|
||||||
|
|
||||||
import * as persisted from '#/state/persisted'
|
import * as persisted from '#/state/persisted'
|
||||||
@@ -18,6 +19,7 @@ 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 {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'
|
||||||
@@ -707,3 +709,22 @@ export function useAgent(): AtpAgent {
|
|||||||
}
|
}
|
||||||
return bundle.agent
|
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()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user