flip types/bsky sources to #/lexicons, add chat client and strict hook contracts
Phase 3 task 2: types/bsky sibling modules (post/profile/starterPack) now source from generated #/lexicons with interim dual-world widening aliases (TODO(phase4) markers). SessionBundle gains a chatClient proxied to did:web:api.bsky.chat#bsky_chat via useChatClient(). usePdsClient() and useChatClient() no longer fall back to the public appview when logged out - they return a client that throws NotAuthenticatedError before any network I/O; useMaybePdsClient()/useMaybeChatClient() cover logged-out-aware callers. RichText pilot: useRichText.ts on @bsky.app/sdk/richtext with detectFacets(pdsClient); RichText.tsx display sink accepts both worlds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,11 +28,14 @@ jest.mock('jwt-decode', () => ({
|
||||
}))
|
||||
|
||||
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||
import {app} from '#/lexicons'
|
||||
import {app, chat} from '#/lexicons'
|
||||
import {
|
||||
buildAccountClient,
|
||||
buildAppviewClient,
|
||||
buildChatClient,
|
||||
getPublicLexClient,
|
||||
getUnauthenticatedClient,
|
||||
NotAuthenticatedError,
|
||||
} from '../clients'
|
||||
import {sessionAccountToSessionData} from '../session-core'
|
||||
import {type SessionAccount} from '../types'
|
||||
@@ -41,6 +44,7 @@ const DID = 'did:plc:example123'
|
||||
const HANDLE = 'alice.test'
|
||||
const SERVICE = 'https://bsky.social'
|
||||
const APPVIEW_PROXY = 'did:web:api.bsky.app#bsky_appview'
|
||||
const CHAT_PROXY = 'did:web:api.bsky.chat#bsky_chat'
|
||||
const CUSTOM_LABELER = 'did:plc:custom-labeler'
|
||||
|
||||
function makeAccount(overrides: Partial<SessionAccount> = {}): SessionAccount {
|
||||
@@ -175,6 +179,72 @@ describe('buildAccountClient', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildChatClient', () => {
|
||||
it('sets the chat atproto-proxy header on a request', async () => {
|
||||
const {seen, fetchMock} = makeCapturingFetch()
|
||||
const session = makeSession(fetchMock)
|
||||
const client = buildChatClient(session)
|
||||
|
||||
await client.call(chat.bsky.convo.listConvos.main).catch(() => {})
|
||||
|
||||
expect(seen.length).toBe(1)
|
||||
expect(seen[0].headers.get('atproto-proxy')).toBe(CHAT_PROXY)
|
||||
})
|
||||
|
||||
it('routes through the session fetchHandler with the bearer token', async () => {
|
||||
const {seen, fetchMock} = makeCapturingFetch()
|
||||
const session = makeSession(fetchMock)
|
||||
const client = buildChatClient(session)
|
||||
|
||||
await client.call(chat.bsky.convo.listConvos.main).catch(() => {})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUnauthenticatedClient', () => {
|
||||
it('is a stable singleton with no did', () => {
|
||||
const client = getUnauthenticatedClient()
|
||||
expect(client.did).toBeUndefined()
|
||||
/* identity is stable so it is safe in React Query keys */
|
||||
expect(getUnauthenticatedClient()).toBe(client)
|
||||
})
|
||||
|
||||
it('rejects on a call, with NotAuthenticatedError as the root cause', async () => {
|
||||
/*
|
||||
* The throwing fetchHandler fires before any network I/O. lex-client wraps
|
||||
* a fetchHandler throw in an XrpcInternalError whose `.cause` is the
|
||||
* original error, so the NotAuthenticatedError surfaces as the cause.
|
||||
*/
|
||||
const client = getUnauthenticatedClient()
|
||||
|
||||
const err = await client
|
||||
.call(chat.bsky.convo.listConvos.main)
|
||||
.then(() => undefined)
|
||||
.catch((e: unknown) => e)
|
||||
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect((err as Error).cause).toBeInstanceOf(NotAuthenticatedError)
|
||||
})
|
||||
|
||||
it('surfaces a NotAuthenticatedError with a stable name and message', async () => {
|
||||
const client = getUnauthenticatedClient()
|
||||
|
||||
const err = await client
|
||||
.call(chat.bsky.convo.listConvos.main)
|
||||
.then(() => undefined)
|
||||
.catch((e: unknown) => e)
|
||||
|
||||
const cause = (err as Error).cause
|
||||
expect(cause).toBeInstanceOf(NotAuthenticatedError)
|
||||
expect((cause as NotAuthenticatedError).name).toBe('NotAuthenticatedError')
|
||||
expect((cause as NotAuthenticatedError).message).toBe(
|
||||
'Not authenticated: this operation requires an active session',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPublicLexClient', () => {
|
||||
it('is an unauthenticated singleton (no session did)', () => {
|
||||
const client = getPublicLexClient()
|
||||
|
||||
@@ -87,6 +87,51 @@ export function buildAccountClient(session: PasswordSession): Client {
|
||||
return new Client(session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the chat client over a {@link PasswordSession}.
|
||||
*
|
||||
* `api.chat.service` (`did:web:api.bsky.chat#bsky_chat`) is passed as the
|
||||
* client's `service`, so lex-client sets `atproto-proxy: did:web:api.bsky.chat#bsky_chat`
|
||||
* on every request. This is exactly what the old per-call `DM_SERVICE_HEADERS`
|
||||
* did, once and centrally, so `chat.bsky.*` calls are proxied to the chat
|
||||
* service.
|
||||
*/
|
||||
export function buildChatClient(session: PasswordSession): Client {
|
||||
return new Client(session, {service: api.chat.service})
|
||||
}
|
||||
|
||||
/** 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'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable {@link Client} that throws {@link NotAuthenticatedError} on any
|
||||
* request, before any network I/O. Used as the logged-out value of write/auth
|
||||
* -only hooks (`usePdsClient`/`useChatClient`) so an unauthenticated write or
|
||||
* chat call fails immediately and legibly instead of silently hitting the
|
||||
* public appview (which would 404/405 with an opaque error).
|
||||
*
|
||||
* A lazily-constructed process-wide singleton, so its identity is stable across
|
||||
* renders - safe to use in React Query keys and as a hook return value. The
|
||||
* `did` is `undefined` (logged out) and the `fetchHandler` throws before
|
||||
* touching the network.
|
||||
*/
|
||||
let unauthedClient: Client | undefined
|
||||
|
||||
export function getUnauthenticatedClient(): Client {
|
||||
unauthedClient ??= new Client({
|
||||
did: undefined,
|
||||
fetchHandler: () => {
|
||||
throw new NotAuthenticatedError()
|
||||
},
|
||||
})
|
||||
return unauthedClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the authed appview client over a {@link PasswordSession}.
|
||||
*
|
||||
|
||||
@@ -19,7 +19,7 @@ import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {com} from '#/lexicons'
|
||||
import {emitSessionDropped} from '../events'
|
||||
import {getPublicLexClient} from './clients'
|
||||
import {getPublicLexClient, getUnauthenticatedClient} from './clients'
|
||||
import {type Action, getInitialState, reducer, type State} from './reducer'
|
||||
import {
|
||||
buildBundle,
|
||||
@@ -571,9 +571,59 @@ export function useAppviewClient(): Client {
|
||||
/**
|
||||
* The account (PDS) lex {@link Client} for the active account. Writes and record
|
||||
* mutations go here - requests hit the user's PDS directly (no appview proxy).
|
||||
* Falls back to the public client when there is no bundle.
|
||||
*
|
||||
* Logged-out contract: returns a stable throwing client
|
||||
* ({@link getUnauthenticatedClient}) that throws `NotAuthenticatedError` on any
|
||||
* request, BEFORE any network I/O. This is the write path - it must NOT fall
|
||||
* back to the public appview, so an unauthenticated write fails immediately and
|
||||
* legibly rather than silently hitting `public.api.bsky.app`. Components may
|
||||
* safely hold this client while logged out; only calling it throws. A component
|
||||
* that genuinely branches on auth state should use {@link useMaybePdsClient}.
|
||||
*/
|
||||
export function usePdsClient(): Client {
|
||||
const bundle = useContext(BundleContext)
|
||||
return bundle?.accountClient ?? getPublicLexClient()
|
||||
return bundle?.accountClient ?? getUnauthenticatedClient()
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat lex {@link Client} for the active account. `chat.bsky.*` calls go
|
||||
* here - proxied to `did:web:api.bsky.chat#bsky_chat`.
|
||||
*
|
||||
* Logged-out contract: returns a stable throwing client
|
||||
* ({@link getUnauthenticatedClient}) that throws `NotAuthenticatedError` on any
|
||||
* request, BEFORE any network I/O. Chat is meaningless logged out, so this must
|
||||
* NOT fall back to the public appview. A component that genuinely branches on
|
||||
* auth state should use {@link useMaybeChatClient}.
|
||||
*/
|
||||
export function useChatClient(): Client {
|
||||
const bundle = useContext(BundleContext)
|
||||
return bundle?.chatClient ?? getUnauthenticatedClient()
|
||||
}
|
||||
|
||||
/**
|
||||
* The account (PDS) lex {@link Client} for the active account, or `null` when
|
||||
* there is no active session (logged out, or used outside the provider).
|
||||
*
|
||||
* The escape hatch for the rare component that genuinely renders a logged-out
|
||||
* branch and must decide whether a write path is available. Prefer
|
||||
* {@link usePdsClient} for the common case (a write only reachable while
|
||||
* authenticated); do NOT reach for this hook merely to dodge the throwing
|
||||
* client's `NotAuthenticatedError`.
|
||||
*/
|
||||
export function useMaybePdsClient(): Client | null {
|
||||
const bundle = useContext(BundleContext)
|
||||
return bundle?.session ? bundle.accountClient : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat lex {@link Client} for the active account, or `null` when there is
|
||||
* no active session (logged out, or used outside the provider).
|
||||
*
|
||||
* The escape hatch for the rare component that genuinely renders a logged-out
|
||||
* branch. Prefer {@link useChatClient} for the common case; do NOT reach for
|
||||
* this hook merely to dodge the throwing client's `NotAuthenticatedError`.
|
||||
*/
|
||||
export function useMaybeChatClient(): Client | null {
|
||||
const bundle = useContext(BundleContext)
|
||||
return bundle?.session ? bundle.chatClient : null
|
||||
}
|
||||
|
||||
@@ -38,7 +38,9 @@ import {features} from '#/analytics'
|
||||
import {
|
||||
buildAccountClient,
|
||||
buildAppviewClient,
|
||||
buildChatClient,
|
||||
getPublicLexClient,
|
||||
getUnauthenticatedClient,
|
||||
} from './clients'
|
||||
import {addSessionErrorLog} from './logging'
|
||||
import {
|
||||
@@ -401,6 +403,8 @@ export type SessionBundle = {
|
||||
accountClient: Client
|
||||
/** Authed appview client (proxied, with labelers). */
|
||||
appviewClient: Client
|
||||
/** Chat client (proxied to `did:web:api.bsky.chat#bsky_chat`). */
|
||||
chatClient: Client
|
||||
/**
|
||||
* The service (entryway) URL, mirroring `agent.serviceUrl`. Exposed so the
|
||||
* reducer can read `.service` for its opaque snapshot/logging view
|
||||
@@ -431,6 +435,7 @@ export function buildBundle(session: PasswordSession): SessionBundle {
|
||||
* appviewClient too.
|
||||
*/
|
||||
appviewClient: buildAppviewClient(session, []),
|
||||
chatClient: buildChatClient(session),
|
||||
/*
|
||||
* Mirror the bridge agent's serviceUrl so the reducer's opaque view can
|
||||
* read `.service`. A getter keeps it live with the agent's derivation.
|
||||
@@ -519,6 +524,13 @@ export type PublicSessionBundle = {
|
||||
agent: SessionAgent
|
||||
accountClient: Client
|
||||
appviewClient: Client
|
||||
/**
|
||||
* The throwing unauthenticated client (NOT the public client): chat is
|
||||
* meaningless logged out, and `useChatClient()` must fail loudly rather than
|
||||
* silently target the public appview. See {@link getUnauthenticatedClient}
|
||||
* and design section J.
|
||||
*/
|
||||
chatClient: Client
|
||||
/** Mirrors `agent.serviceUrl` (the public appview URL). See {@link SessionBundle.service}. */
|
||||
readonly service: URL
|
||||
}
|
||||
@@ -536,8 +548,16 @@ export function createPublicSessionBundle(): PublicSessionBundle {
|
||||
return {
|
||||
session: null,
|
||||
agent,
|
||||
accountClient: publicClient,
|
||||
/*
|
||||
* Write/auth clients throw on use when logged out (design section J): the
|
||||
* public bundle exposes the throwing unauthenticated client for the account
|
||||
* (PDS) and chat clients so an unauthenticated write or chat call fails
|
||||
* loudly instead of silently targeting the public appview. Reads keep the
|
||||
* public client (appviewClient), which reads public data without auth.
|
||||
*/
|
||||
accountClient: getUnauthenticatedClient(),
|
||||
appviewClient: publicClient,
|
||||
chatClient: getUnauthenticatedClient(),
|
||||
get service() {
|
||||
return agent.serviceUrl
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user