[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:
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
getMain,
|
||||
type Procedure,
|
||||
type Query,
|
||||
XrpcInternalError,
|
||||
XrpcResponseError,
|
||||
} from '@atproto/lex'
|
||||
import {describe, expect, it} from '@jest/globals'
|
||||
|
||||
import {app, com} from '#/lexicons'
|
||||
import {matchXrpcError} from '../xrpc-error'
|
||||
|
||||
const createAccount = com.atproto.server.createAccount
|
||||
const getTrends = app.bsky.unspecced.getTrends
|
||||
|
||||
/**
|
||||
* An `XrpcResponseError` as a lex `Client` would construct it: the method
|
||||
* schema it was thrown for, plus the server's error response and its parsed
|
||||
* payload.
|
||||
*/
|
||||
function responseError(method: Procedure | Query, error: string, status = 400) {
|
||||
return new XrpcResponseError(
|
||||
method,
|
||||
new Response(JSON.stringify({error}), {
|
||||
status,
|
||||
headers: {'content-type': 'application/json'},
|
||||
}),
|
||||
{encoding: 'application/json', body: {error}},
|
||||
)
|
||||
}
|
||||
|
||||
describe('matchXrpcError', () => {
|
||||
it('returns a code declared by the method', () => {
|
||||
const e = responseError(getMain(createAccount), 'InvalidHandle')
|
||||
expect(matchXrpcError(e, createAccount)).toBe('InvalidHandle')
|
||||
})
|
||||
|
||||
it('accepts the .main schema as well as the namespace', () => {
|
||||
const e = responseError(getMain(createAccount), 'InvalidInviteCode')
|
||||
expect(matchXrpcError(e, createAccount.main)).toBe('InvalidInviteCode')
|
||||
})
|
||||
|
||||
it('returns undefined for a code the method does not declare', () => {
|
||||
const e = responseError(getMain(createAccount), 'RateLimitExceeded')
|
||||
expect(matchXrpcError(e, createAccount)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for a method that declares no errors at all', () => {
|
||||
const e = responseError(getMain(getTrends), 'InvalidHandle')
|
||||
expect(matchXrpcError(e, getTrends)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not match a declared code thrown for a different method', () => {
|
||||
/*
|
||||
* `InvalidHandle` is declared by createAccount but this error came from a
|
||||
* getTrends call, so scoping must reject it.
|
||||
*/
|
||||
const e = responseError(getMain(getTrends), 'InvalidHandle')
|
||||
expect(matchXrpcError(e, createAccount)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for a lex error carrying no server code', () => {
|
||||
const e = new XrpcInternalError(getMain(createAccount), 'boom')
|
||||
expect(matchXrpcError(e, createAccount)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for non-lex errors and non-errors', () => {
|
||||
expect(matchXrpcError(new Error('InvalidHandle'), createAccount)).toBe(
|
||||
undefined,
|
||||
)
|
||||
expect(matchXrpcError('InvalidHandle', createAccount)).toBeUndefined()
|
||||
expect(matchXrpcError(undefined, createAccount)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('narrows the result to the declared-errors union', () => {
|
||||
const e = responseError(getMain(createAccount), 'InvalidHandle')
|
||||
const code = matchXrpcError(e, createAccount)
|
||||
|
||||
/*
|
||||
* A misspelled or undeclared code is not comparable to the narrowed union,
|
||||
* which is what makes a typo'd `switch` case a compile error (TS2678 /
|
||||
* TS2367) rather than a branch that never runs.
|
||||
*/
|
||||
// @ts-expect-error 'InvalidHandel' is not a declared createAccount error
|
||||
expect(code === 'InvalidHandel').toBe(false)
|
||||
// @ts-expect-error 'RateLimitExceeded' is not declared by createAccount
|
||||
expect(code === 'RateLimitExceeded').toBe(false)
|
||||
|
||||
expect(code === 'UnsupportedDomain').toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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,59 @@
|
||||
import {
|
||||
getMain,
|
||||
type InferMethodError,
|
||||
type Main,
|
||||
type Procedure,
|
||||
type Query,
|
||||
XrpcResponseError,
|
||||
} from '@atproto/lex'
|
||||
|
||||
/**
|
||||
* Same nsid means `e` was thrown for this method schema, so `e` can be
|
||||
* treated as an `XrpcResponseError<M>` - which is what lets the SDK's
|
||||
* `matchesSchemaErrors()` narrow `e.error` to M's declared errors.
|
||||
*/
|
||||
function isThrownFor<M extends Procedure | Query>(
|
||||
e: XrpcResponseError,
|
||||
schema: M,
|
||||
): e is XrpcResponseError<M> {
|
||||
return e.method.nsid === schema.nsid
|
||||
}
|
||||
|
||||
/**
|
||||
* The lexicon error code carried by `e`, narrowed to the errors DECLARED by
|
||||
* `method`, or `undefined` when `e` is not such an error.
|
||||
*
|
||||
* `XrpcResponseError.error` is the open `LexErrorCode` union, so comparing it
|
||||
* as a plain string lets a typo silently never match. Narrowing the return type
|
||||
* to `InferMethodError<M>` makes a `switch` over the result reject an
|
||||
* undeclared or misspelled `case` at compile time:
|
||||
*
|
||||
* ```ts
|
||||
* switch (matchXrpcError(e, com.atproto.server.createAccount)) {
|
||||
* case 'InvalidHandle':
|
||||
* ...
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Matching is scoped to `method`: `XrpcError` records the method schema it was
|
||||
* thrown for, so a declared code arriving from a DIFFERENT call does not match.
|
||||
* Undeclared codes, non-XRPC errors, and the internal/fetch lex errors (which
|
||||
* carry no server error code) all return `undefined`.
|
||||
*
|
||||
* `method` accepts the same value passed to `client.call` - either the
|
||||
* generated method namespace (`com.atproto.server.createAccount`) or its
|
||||
* `.main` schema - via lex's `Main<M>`.
|
||||
*/
|
||||
export function matchXrpcError<M extends Procedure | Query>(
|
||||
e: unknown,
|
||||
method: Main<M>,
|
||||
): InferMethodError<M> | undefined {
|
||||
if (!(e instanceof XrpcResponseError)) {
|
||||
return undefined
|
||||
}
|
||||
const schema = getMain(method)
|
||||
if (isThrownFor(e, schema) && e.matchesSchemaErrors()) {
|
||||
return e.error
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
+13
-30
@@ -1,7 +1,6 @@
|
||||
import {createContext, useCallback, useContext} from 'react'
|
||||
import {LayoutAnimation} from 'react-native'
|
||||
import {type ComAtprotoServerDescribeServer} from '@atproto/api'
|
||||
import {XrpcResponseError} from '@atproto/lex'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import * as EmailValidator from 'email-validator'
|
||||
|
||||
@@ -9,9 +8,11 @@ import {DEFAULT_SERVICE} from '#/lib/constants'
|
||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||
import {createFullHandle} from '#/lib/strings/handles'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {matchXrpcError} from '#/lib/xrpc-error'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
import {type AnalyticsContextType, useAnalytics} from '#/analytics'
|
||||
import {com} from '#/lexicons'
|
||||
|
||||
export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
|
||||
|
||||
@@ -258,34 +259,13 @@ export const useSignupContext = () => useContext(SignupContext)
|
||||
* failure is unexpected and should be reported to Sentry.
|
||||
*/
|
||||
function classifyExpectedSignupError(e: unknown): string | undefined {
|
||||
/*
|
||||
* TODO: `XrpcResponseError.error` is the open `LexErrorCode` union, so these
|
||||
* codes are compared as plain strings and a typo silently never matches.
|
||||
* Once the generated lexicons land, replace this (and every multi-code error
|
||||
* site) with a shared helper that narrows against the method schema:
|
||||
*
|
||||
* function matchXrpcError<M extends Procedure | Query>(
|
||||
* e: unknown,
|
||||
* method: Main<M>,
|
||||
* ): InferMethodError<M> | undefined
|
||||
*
|
||||
* switch (matchXrpcError(e, com.atproto.server.createAccount)) {
|
||||
* case 'InvalidHandle': ...
|
||||
* }
|
||||
*
|
||||
* The return type is the method's declared-errors union, so a typo'd case is
|
||||
* a compile error, and undeclared codes fall through to `undefined`. The
|
||||
* helper should also match `e.method.nsid` so a declared code from a
|
||||
* different call cannot match, mirroring the old per-method error classes.
|
||||
*/
|
||||
if (e instanceof XrpcResponseError) {
|
||||
switch (e.error) {
|
||||
case 'InvalidHandle':
|
||||
case 'HandleNotAvailable':
|
||||
case 'InvalidPassword':
|
||||
case 'UnsupportedDomain':
|
||||
return e.error
|
||||
}
|
||||
const code = matchXrpcError(e, com.atproto.server.createAccount)
|
||||
switch (code) {
|
||||
case 'InvalidHandle':
|
||||
case 'HandleNotAvailable':
|
||||
case 'InvalidPassword':
|
||||
case 'UnsupportedDomain':
|
||||
return code
|
||||
}
|
||||
/* the server sends no typed error for this case */
|
||||
if (String(e).includes('Email already taken')) return 'EmailTaken'
|
||||
@@ -376,7 +356,10 @@ export function useSubmitSignup() {
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
let errMsg = e.toString()
|
||||
if (e instanceof XrpcResponseError && e.error === 'InvalidInviteCode') {
|
||||
if (
|
||||
matchXrpcError(e, com.atproto.server.createAccount) ===
|
||||
'InvalidInviteCode'
|
||||
) {
|
||||
dispatch({
|
||||
type: 'setError',
|
||||
value: l`Invite code not accepted. Check that you input it correctly and try again.`,
|
||||
|
||||
@@ -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,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,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,
|
||||
}))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user