migrate the logged-out feed fetch to a direct appview lex client

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-04 14:22:52 +03:00
parent 8ab626510d
commit 37b7a7b0f9
+76 -51
View File
@@ -1,5 +1,11 @@
import {Client, lexParse, type XrpcRequestParams} from '@atproto/lex' import {
type Client,
type XrpcRequestParams,
XrpcResponseError,
} from '@atproto/lex'
import {PUBLIC_APPVIEW} from '#/lib/constants'
import {createLexClient} from '#/lib/lexClient'
import { import {
getAppLanguageAsContentLanguage, getAppLanguageAsContentLanguage,
getContentLanguages, getContentLanguages,
@@ -96,68 +102,87 @@ export class CustomFeedAPI implements FeedAPI {
} }
} }
// HACK let loggedOutAppviewClient: Client | undefined
// we want feeds to give language-specific results immediately when a
// logged-out user changes their language. this comes with two problems:
// 1. not all languages have content, and
// 2. our public caching layer isnt correctly busting against the accept-language header
// for now we handle both of these with a manual workaround
// -prf
async function loggedOutFetch({
feed,
limit,
cursor,
}: {
feed: string
limit: number
cursor?: string
}): Promise<app.bsky.feed.getFeed.$OutputBody | null> {
let contentLangs = getAppLanguageAsContentLanguage()
/* /**
* This request is hand-rolled rather than issued through a client, so it has * The unauthenticated {@link Client} for logged-out feed reads, pointed at the
* to reproduce the header lex would have emitted from the global static. * direct appview ({@link PUBLIC_APPVIEW}, `api.bsky.app`).
*
* Deliberately NOT the public appview client (`public.api.bsky.app`): that host
* fronts a cache which does not vary on `Accept-Language`, so it would answer a
* language-filtered read from another language's cached body. The direct
* appview respects the header (verified 2026-08-04), at the cost of not being
* cached. See {@link loggedOutFetch}.
*
* A single module-level instance, because there is no session to scope it to.
* Like the public chat client, it uses plain `fetch` rather than
* `networkAwareFetch`, matching the ad-hoc fetch it replaces: this read has its
* own failure handling and should not move the app-wide network signal.
*/ */
const labelersHeader = { function getLoggedOutAppviewClient(): Client {
'atproto-accept-labelers': Client.appLabelers return (loggedOutAppviewClient ??= createLexClient({
.map(l => `${l};redact`) service: PUBLIC_APPVIEW,
.join(', '), }))
} }
// manually construct fetch call so we can add the `lang` cache-busting param /*
let res = await fetch( * HACK
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${ * We want feeds to give language-specific results immediately when a logged-out
cursor ? `&cursor=${cursor}` : '' * user changes their language. That comes with two problems:
}&limit=${limit}&lang=${contentLangs}`, * 1. not all languages have content, and
{ * 2. our public caching layer does not bust against the `Accept-Language`
method: 'GET', * header.
headers: {'Accept-Language': contentLangs, ...labelersHeader}, * -prf
}, *
) * Problem 2 is why this uses its own client rather than the app's public
/* * appview one: it talks to the direct appview, which honors the header, instead
* The response is hand-decoded rather than validated, so the lex output shape * of the cached `public.api.bsky.app`, which does not vary on it. That trades
* is asserted here. * CDN caching for language correctness on logged-out feed traffic.
*
* Problem 1 is host-independent, so it is still handled here: an empty
* language-filtered feed is retried once with the language constraint removed.
*/ */
let data = res.ok async function loggedOutFetch(
? (lexParse(await res.text()) as app.bsky.feed.getFeed.$OutputBody) params: GetCustomFeedParams,
: null ): Promise<app.bsky.feed.getFeed.$OutputBody | null> {
const contentLangs = getAppLanguageAsContentLanguage()
let data = await getFeedOrNull(params, contentLangs)
if (data?.feed?.length) { if (data?.feed?.length) {
return data return data
} }
// no data, try again with language headers removed // no data, try again with language headers removed
res = await fetch( data = await getFeedOrNull(params, '')
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
cursor ? `&cursor=${cursor}` : ''
}&limit=${limit}`,
{method: 'GET', headers: {'Accept-Language': '', ...labelersHeader}},
)
data = res.ok
? (lexParse(await res.text()) as app.bsky.feed.getFeed.$OutputBody)
: null
if (data?.feed?.length) { if (data?.feed?.length) {
return data return data
} }
return null return null
} }
/**
* A logged-out `getFeed` read that resolves to null on a response error.
*
* The pre-client code only guarded `res.ok`, so a failed RESPONSE fell through
* to the next attempt while a failed REQUEST rejected. Catching
* `XrpcResponseError` preserves that split: every other lex error - the fetch
* and validation ones - still propagates.
*/
async function getFeedOrNull(
params: GetCustomFeedParams,
contentLangs: string,
): Promise<app.bsky.feed.getFeed.$OutputBody | null> {
try {
return await getLoggedOutAppviewClient().call(
app.bsky.feed.getFeed,
params,
{headers: {'Accept-Language': contentLangs}},
)
} catch (e) {
if (e instanceof XrpcResponseError) {
return null
}
throw e
}
}