Files
bsky-social-app/src/state/session/clients.ts
T
2026-08-26 12:54:08 +01:00

156 lines
6.1 KiB
TypeScript

import {type Agent, type Client} from '@atproto/lex'
import {type PasswordSession} from '@atproto/lex-password-session'
import {
BLUESKY_PROXY_HEADER,
CHAT_PROXY_SERVICE,
PUBLIC_BSKY_SERVICE,
} from '#/lib/constants'
import {createLexClient} from '#/lib/lexClient'
import {networkAwareFetch} from './network'
/**
* Build the signed-in appview {@link Client}.
*
* {@link BLUESKY_PROXY_HEADER} is passed as the client's `service`, so lex sets
* `atproto-proxy: <that value>` on every request and raw calls are proxied to
* the appview. Record helpers force `service: null`, so they still target the
* account host.
*
* The class-wide `Client.appLabelers` static is deliberately NOT suppressed
* here: this client is the only producer of `atproto-accept-labelers` on an
* appview request now that no agent sits underneath it. The account's own
* subscriptions arrive separately, through `applyLabelersToClient` on the
* instance, and that function filters out the Bluesky moderation DID so the
* globally redacted authority is not also listed unredacted.
*
* No `fetch` option: a client built over a session uses that session's own
* fetch, which is `networkAwareFetch` wrapped in the disposal kill switch.
*/
export function buildAppviewClient(agent: Agent): Client {
return createLexClient(agent, {service: BLUESKY_PROXY_HEADER.get()})
}
/**
* Build the signed-in account-host {@link Client}.
*
* No `service`, so no proxy header: `com.atproto.*` repo, server and identity
* calls reach the account's own PDS rather than being proxied onward.
*
* `appLabelers: null` suppresses the class-wide static for this instance. A PDS
* request is not an appview read, so it must carry no moderation authorities at
* all; without the suppression it would start emitting the global list.
*/
export function buildPdsClient(agent: Agent): Client {
return createLexClient(agent, {appLabelers: null})
}
/**
* Build the signed-in chat {@link Client}.
*
* {@link CHAT_PROXY_SERVICE} (`${CHAT_PROXY_DID}#bsky_chat`, default
* `did:web:api.bsky.chat#bsky_chat`) is the client's `service`, so `chat.bsky.*`
* calls are proxied to the chat service. The DID is read from the
* env-configurable `CHAT_PROXY_DID` rather than a hard-coded constant, so it can
* be retargeted per environment.
*
* Unlike the PDS client, chat carries moderation authorities. The service uses
* them to hydrate labels on profiles embedded in conversation responses, so
* this client reads the global `Client.appLabelers` and receives the account's
* subscriptions through `configureModerationForAccount`.
*/
export function buildChatClient(agent: Agent): Client {
return createLexClient(agent, {
service: CHAT_PROXY_SERVICE,
})
}
/**
* Wrap a session so requests resolve against a known PDS while auth and refresh
* stay with the session.
*
* This exists for the pre-didDoc window. `PasswordSession` resolves each request
* against `extractPdsUrl(didDoc) ?? service`, so before a refresh has delivered
* a didDoc it falls back to the login service - which for an entryway account
* (`service: bsky.social`, PDS elsewhere) is the wrong host. The synchronous
* resume fast path makes no network request at all, so that window covers every
* request of a cold start until something triggers a refresh.
*
* Absolutizing here is enough because `PasswordSession.fetchHandler` builds its
* URL with `new URL(path, base)`, which ignores the base for an already-absolute
* input. So an absolute URL passes through untouched, and the session's own
* didDoc routing still wins for any client built directly over it.
*
* The tradeoff is that this pins the STORED url for the bundle's lifetime, where
* the session would prefer a didDoc endpoint that arrived later. That is
* acceptable because the two only disagree if the account's PDS moved, and the
* next cold start persists (and therefore pins) the new endpoint.
*/
export function routeSessionToPds(
session: PasswordSession,
pdsUrl: string,
): Agent {
return {
get did() {
return session.did
},
fetchHandler(path, init) {
return session.fetchHandler(new URL(path, pdsUrl).href, init)
},
}
}
/** 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
/**
* The unauthenticated {@link Client} for public reads, pointed at the public
* appview.
*
* A single module-level instance: there is no session to scope it to, so it
* lives for the lifetime of the process, and its identity is therefore stable
* enough for a React Query key. Requests go through {@link networkAwareFetch} so
* public reads feed the app's reachability signal like authenticated ones do.
*
* Like the session appview client, it carries the class-wide
* `Client.appLabelers`, so a logged-out read gets the same `;redact` moderation
* authorities an authenticated one does. That makes `configureModerationForGuest`
* load-bearing rather than test-only - it is what populates the static before
* this client's first request, and `createPublicSessionBundle` runs it while
* building the bundle.
*/
export function getPublicAppviewClient(): Client {
return (publicLexClient ??= createLexClient({
service: PUBLIC_BSKY_SERVICE,
fetch: networkAwareFetch,
}))
}