add createLexClient factory with strictResponseProcessing: false

lex-client's strict mode rejects the legacy blob reference format
({cid, mimeType}) still present in older records; the old stack
tolerated it. Route all client construction through the factory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-19 13:19:26 +03:00
parent 5541a48552
commit ffba34c548
11 changed files with 55 additions and 35 deletions
+3 -4
View File
@@ -1,6 +1,6 @@
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
import * as AgeRange from 'expo-age-range'
import {Client} from '@atproto/lex'
import {type Client} from '@atproto/lex'
import {getPreferences} from '@bsky.app/sdk'
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
@@ -9,6 +9,7 @@ import debounce from 'lodash.debounce'
import {networkRetry} from '#/lib/async/retry'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {createLexClient} from '#/lib/lexClient'
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
import {getAge} from '#/lib/strings/time'
import {
@@ -99,9 +100,7 @@ export function setBirthdateForDid({
export const configQueryKey = ['config']
export async function getConfig() {
if (debug.enabled) return debug.resolve(debug.config)
const client = new Client({
service: PUBLIC_BSKY_SERVICE,
})
const client = createLexClient({service: PUBLIC_BSKY_SERVICE})
return await client.call(app.bsky.ageassurance.getConfig)
}
export function getConfigFromCache():
+2 -2
View File
@@ -1,5 +1,4 @@
import {Platform} from 'react-native'
import {Client} from '@atproto/lex'
import {useMutation} from '@tanstack/react-query'
import {wait} from '#/lib/async/wait'
@@ -9,6 +8,7 @@ import {
PUBLIC_APPVIEW_DID,
} from '#/lib/constants'
import {isNetworkError} from '#/lib/hooks/useCleanError'
import {createLexClient} from '#/lib/lexClient'
import {usePdsClient} from '#/state/session'
import {usePatchAgeAssuranceServerState} from '#/ageAssurance'
import {logger} from '#/ageAssurance/logger'
@@ -50,7 +50,7 @@ export function useBeginAgeAssurance() {
* appview with the token as a static Authorization header (a raw client,
* unlike a session, is allowed to preset that header).
*/
const scopedClient = new Client({
const scopedClient = createLexClient({
service: APPVIEW,
headers: {authorization: `Bearer ${token}`},
})
+27
View File
@@ -0,0 +1,27 @@
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 the old @atproto/api stack tolerated them, so
* strict mode would be a behavior regression. Lenient mode also relaxes
* datetime format checks (e.g. missing timezones) and blob MIME/size
* constraints, again matching the old stack's tolerance. `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})
}
+3 -6
View File
@@ -1,6 +1,5 @@
import {Client} from '@atproto/lex'
import {type SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
import {createLexClient} from '#/lib/lexClient'
export const createVideoEndpointUrl = (
route: string,
@@ -25,7 +24,7 @@ export const createVideoEndpointUrl = (
* `#/ageAssurance/useBeginAgeAssurance`.
*/
export function createVideoServiceClient(token: string) {
return new Client({
return createLexClient({
service: VIDEO_SERVICE,
headers: {authorization: `Bearer ${token}`},
})
@@ -37,9 +36,7 @@ export function createVideoServiceClient(token: string) {
* `AtpAgent` at `VIDEO_SERVICE`.
*/
export function createTokenlessVideoServiceClient() {
return new Client({
service: VIDEO_SERVICE,
})
return createLexClient({service: VIDEO_SERVICE})
}
export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
+2 -2
View File
@@ -1,9 +1,9 @@
import {useCallback, useState} from 'react'
import {Keyboard, View} from 'react-native'
import {Client} from '@atproto/lex'
import {Trans, useLingui} from '@lingui/react/macro'
import * as EmailValidator from 'email-validator'
import {createLexClient} from '#/lib/lexClient'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {atoms as a, useTheme, web} from '#/alf'
@@ -55,7 +55,7 @@ export const ForgotPasswordForm = ({
setIsProcessing(true)
try {
const client = new Client({service: serviceUrl})
const client = createLexClient({service: serviceUrl})
await client.call(com.atproto.server.requestPasswordReset, {email})
onEmailSent()
} catch (err) {
+2 -2
View File
@@ -1,8 +1,8 @@
import {useState} from 'react'
import {View} from 'react-native'
import {Client} from '@atproto/lex'
import {Trans, useLingui} from '@lingui/react/macro'
import {createLexClient} from '#/lib/lexClient'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {checkAndFormatResetCode} from '#/lib/strings/password'
import {logger} from '#/logger'
@@ -62,7 +62,7 @@ export const SetNewPasswordForm = ({
setIsProcessing(true)
try {
const client = new Client({service: serviceUrl})
const client = createLexClient({service: serviceUrl})
await client.call(com.atproto.server.resetPassword, {
token: formattedCode,
password,
+3 -3
View File
@@ -1,4 +1,3 @@
import {Client} from '@atproto/lex'
import {type DatetimeString, type HandleString} from '@atproto/syntax'
import {useQuery} from '@tanstack/react-query'
@@ -8,6 +7,7 @@ import {
PUBLIC_BSKY_SERVICE,
} from '#/lib/constants'
import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
import {createLexClient} from '#/lib/lexClient'
import {createFullHandle} from '#/lib/strings/handles'
import {useAnalytics} from '#/analytics'
import {com} from '#/lexicons'
@@ -81,7 +81,7 @@ export async function checkHandleAvailability(
) {
if (serviceDid === BSKY_SERVICE_DID) {
// entryway has a special API for handle availability
const client = new Client({service: BSKY_SERVICE})
const client = createLexClient({service: BSKY_SERVICE})
const data = await client.call(com.atproto.temp.checkHandleAvailability, {
handle: handle as HandleString,
birthDate: birthDate as DatetimeString | undefined,
@@ -114,7 +114,7 @@ export async function checkHandleAvailability(
}
} else {
// 3rd party PDSes won't have this API so just try and resolve the handle
const client = new Client({service: PUBLIC_BSKY_SERVICE})
const client = createLexClient({service: PUBLIC_BSKY_SERVICE})
try {
const res = await client.call(com.atproto.identity.resolveHandle, {
handle: handle as HandleString,
+3 -2
View File
@@ -1,9 +1,10 @@
import {useCallback} from 'react'
import {type $Typed, Client} from '@atproto/lex'
import {type $Typed, type Client} from '@atproto/lex'
import {toDatetimeString} from '@atproto/syntax'
import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import {CHAT_SERVICE} from '#/lib/constants'
import {createLexClient} from '#/lib/lexClient'
import {logger} from '#/logger'
import {STALE} from '#/state/queries/index'
import {createQueryKey, type StructuredQueryKey} from '#/state/queries/util'
@@ -19,7 +20,7 @@ import * as bsky from '#/types/bsky'
*/
let publicChatClient: Client | undefined
function getPublicChatClient(): Client {
publicChatClient ??= new Client({service: CHAT_SERVICE})
publicChatClient ??= createLexClient({service: CHAT_SERVICE})
return publicChatClient
}
+2 -2
View File
@@ -1,11 +1,11 @@
import {useState} from 'react'
import {type DidDocument, getPdsEndpoint} from '@atproto/common-web'
import {Client} from '@atproto/lex'
import {type HandleString} from '@atproto/syntax'
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {DEFAULT_SERVICE, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
import {createLexClient} from '#/lib/lexClient'
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
@@ -153,7 +153,7 @@ export async function resolvePdsForIdentifier(
* Unauthenticated throwaway client pointed at the public appview -
* resolveHandle is a public read.
*/
const client = new Client({service: PUBLIC_BSKY_SERVICE})
const client = createLexClient({service: PUBLIC_BSKY_SERVICE})
try {
let did: string
if (norm.startsWith('did:')) {
+2 -2
View File
@@ -1,6 +1,6 @@
import {Client} from '@atproto/lex'
import {useQuery} from '@tanstack/react-query'
import {createLexClient} from '#/lib/lexClient'
import {com} from '#/lexicons'
const RQKEY_ROOT = 'service'
@@ -14,7 +14,7 @@ export function useServiceQuery(serviceUrl: string) {
* Unauthenticated throwaway client pointed at the candidate service -
* describeServer is a public endpoint on the target PDS/entryway.
*/
const client = new Client({service: serviceUrl})
const client = createLexClient({service: serviceUrl})
return await client.call(com.atproto.server.describeServer)
},
enabled: isValidUrl(serviceUrl),
+6 -10
View File
@@ -1,14 +1,14 @@
import {Client} from '@atproto/lex'
import {PasswordSession} from '@atproto/lex-password-session'
import {isJwtExpired} from '#/lib/jwt'
import {createLexClient} from '#/lib/lexClient'
import {type TemporaryPushClient} from '#/lib/notifications/notifications'
import * as persisted from '#/state/persisted'
import {networkAwareFetch, sessionAccountToSessionData} from './session-core'
import {type SessionAccount} from './types'
/*
* Canonical implementation moved to session-core.ts so that module stays
* Canonical implementation lives in session-core.ts so that module stays
* dependency-light (this file transitively pulls in a large chunk of the app).
* Re-exported here for existing consumers.
*/
@@ -28,18 +28,14 @@ export function isSessionExpired(account: SessionAccount) {
}
/**
* Creates and resumes a throwaway session for every stored account.
* Intended to send push token revocations just before logout.
* Creates and resumes a throwaway session for every stored account. Intended to
* send push token revocations just before logout.
*
* Each returned {@link TemporaryPushClient} wraps a temporary `PasswordSession`
* resumed over the network to obtain a valid access token. These sessions are
* resumed over the network for a valid access token. These sessions are
* deliberately hook-free (no `onUpdated`/`onDeleted`): they must NEVER persist
* or race the active session. They are used once for the unregister call and
* discarded (reclaimed by GC), so we never call `logout()` on them.
*
* Each session is wrapped in a plain account-shaped `Client` (no proxy header)
* paired with the account's service origin and handle, matching the contract
* {@link unregisterPushToken} consumes.
*/
export async function createTemporaryClientsAndResume(
accounts: SessionAccount[],
@@ -51,7 +47,7 @@ export async function createTemporaryClientsAndResume(
{fetch: networkAwareFetch},
)
return {
client: new Client(session),
client: createLexClient(session),
service: session.session.service,
handle: session.session.handle,
} satisfies TemporaryPushClient