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:
+74
-49
@@ -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(
|
|
||||||
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
|
|
||||||
cursor ? `&cursor=${cursor}` : ''
|
|
||||||
}&limit=${limit}&lang=${contentLangs}`,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: {'Accept-Language': contentLangs, ...labelersHeader},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
/*
|
/*
|
||||||
* The response is hand-decoded rather than validated, so the lex output shape
|
* HACK
|
||||||
* is asserted here.
|
* We want feeds to give language-specific results immediately when a logged-out
|
||||||
|
* user changes their language. That comes with two problems:
|
||||||
|
* 1. not all languages have content, and
|
||||||
|
* 2. our public caching layer does not bust against the `Accept-Language`
|
||||||
|
* header.
|
||||||
|
* -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
|
||||||
|
* of the cached `public.api.bsky.app`, which does not vary on it. That trades
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user