From cb97b6551a4ccc85f2ac7937e9596fd06bfb47c0 Mon Sep 17 00:00:00 2001 From: scanash00 Date: Fri, 12 Jun 2026 12:16:39 -0800 Subject: [PATCH] Revert custom feeds to the working Slingshot batch path The red-dwarf PDS-proxy experiment broke feeds in practice, so revert custom.ts and feed.ts to b66e942: Slingshot's batch proxy with the Bearer-prefixed service-auth token, plus per-post counts. This is the version that loaded personalized feeds with stats. --- src/lib/api/feed/custom.ts | 39 ++++++++--------- src/lib/microcosm/feed.ts | 89 ++++++++++++++++---------------------- 2 files changed, 56 insertions(+), 72 deletions(-) diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index 2e27474..293e1e4 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -52,32 +52,29 @@ export class CustomFeedAPI implements FeedAPI { cursor: string | undefined limit: number }): Promise { - // Custom feed without the AppView. When logged in, we fetch the (possibly - // personalized) skeleton by proxying getFeedSkeleton through the viewer's - // OWN PDS with the `atproto-proxy: #bsky_fg` header: the PDS - // authenticates the session, mints/forwards service-auth, and proxies to the - // generator. No AppView, no manual token, and no CORS wall (the PDS serves - // CORS). Records are hydrated from microcosm. Logged out falls back to - // Slingshot's public batch path inside buildCustomFeed. + // Microcosm batch path: Slingshot proxies the feed generator's skeleton and + // fetches all post records in one request. For personalized feeds we forward + // a service-auth token (aud = the feed generator's DID, lxm = + // getFeedSkeleton) so the generator can identify the viewer, exactly as the + // AppView would. Slingshot forwards it to the generator; without it, + // personalized feeds (e.g. For You) reject the request. if (MICROCOSM_ENABLED) { try { - const agent = this.agent const page = await buildCustomFeed(this.params.feed, { - viewerDid: agent.did, + viewerDid: this.agent.did, limit, cursor, - getSkeleton: agent.did - ? async ({feedGenDid, feed, limit: lim, cursor: cur}) => { - const res = await agent - .withProxy('bsky_fg', feedGenDid) - .app.bsky.feed.getFeedSkeleton({ - feed, - limit: lim, - cursor: cur, - }) - return { - order: res.data.feed, - cursor: res.data.cursor, + getAuthorization: this.agent.did + ? async (feedGenDid: string) => { + try { + const {data} = + await this.agent.com.atproto.server.getServiceAuth({ + aud: feedGenDid, + lxm: 'app.bsky.feed.getFeedSkeleton', + }) + return data.token + } catch { + return undefined } } : undefined, diff --git a/src/lib/microcosm/feed.ts b/src/lib/microcosm/feed.ts index 3dc5b81..db14695 100644 --- a/src/lib/microcosm/feed.ts +++ b/src/lib/microcosm/feed.ts @@ -149,24 +149,13 @@ async function resolveFeedGenDid( return (rec?.value as {did?: string} | undefined)?.did } -/** An ordered feed skeleton (post at-uris) + paging cursor. */ -export type FeedSkeleton = { - order: Array<{post: string; feedContext?: string}> - cursor?: string -} - /** * Fetch + hydrate one page of a custom (algorithmic) feed, with no AppView. * - * The skeleton (ordered post uris) comes from `getSkeleton` when provided - - * which the caller implements by proxying getFeedSkeleton through the viewer's - * OWN PDS (header `atproto-proxy: #bsky_fg`). The PDS handles session - * auth + service-auth + forwarding to the generator, so personalized feeds work, - * with no AppView, no manual token minting, and no CORS wall (your PDS serves - * CORS). When `getSkeleton` is omitted (logged out) we fall back to Slingshot's - * batch proxy for the public skeleton. - * - * Post records are then hydrated via Slingshot (with counts/viewer state). + * Uses Slingshot's batch proxy: it forwards the (personalized) getFeedSkeleton + * to the generator AND fetches every referenced post record in ONE server-side + * request. The viewer's service-auth token is forwarded so personalized feeds + * work. Returns an empty page on failure so the caller can fall back. */ export async function buildCustomFeed( feedUri: string, @@ -175,55 +164,53 @@ export async function buildCustomFeed( limit?: number cursor?: string /** - * Fetch the feed skeleton for `feedGenDid`. Implemented by the caller via a - * PDS-proxied getFeedSkeleton (see above). Omit when logged out. + * Mint a service-auth token scoped to the feed generator (aud = its DID, + * lxm = app.bsky.feed.getFeedSkeleton). Called after the gen DID is known. + * Required for personalized feeds; omit when logged out. */ - getSkeleton?: (args: { - feedGenDid: string - feed: string - limit: number - cursor?: string - }) => Promise + getAuthorization?: (feedGenDid: string) => Promise } = {}, signal?: AbortSignal, ): Promise { const feedGenDid = await resolveFeedGenDid(feedUri, signal) if (!feedGenDid) return {feed: []} - const limit = opts.limit ?? 30 - - let skeleton: FeedSkeleton | undefined - if (opts.getSkeleton) { - skeleton = await opts - .getSkeleton({feedGenDid, feed: feedUri, limit, cursor: opts.cursor}) - .catch(() => undefined) - } else { - // Logged out: Slingshot's batch proxy serves the public skeleton. - const batch = await hydrateFeedSkeleton( - {feedGenDid, feed: feedUri, limit, cursor: opts.cursor}, - signal, - ).catch(() => undefined) - if (batch) skeleton = {order: batch.order, cursor: batch.cursor} - } - if (!skeleton || skeleton.order.length === 0) return {feed: []} + const authorization = opts.getAuthorization + ? await opts.getAuthorization(feedGenDid).catch(() => undefined) + : undefined - // Hydrate each post uri: record + author via Slingshot (cached/deduped), plus - // counts + viewer state from Constellation. + // Slingshot's batch proxy fetches the (personalized) skeleton AND every post + // record in one request. It runs server-side, so it works on web too (no CORS + // wall like a direct generator call). The viewer's service-auth token is + // forwarded to the generator with the required `Bearer` scheme. + const batch = await hydrateFeedSkeleton( + { + feedGenDid, + feed: feedUri, + limit: opts.limit ?? 30, + cursor: opts.cursor, + authorization, + }, + signal, + ).catch(() => undefined) + if (!batch || batch.order.length === 0) return {feed: []} + + // Preserve the generator's ordering; hydrate author-only from the records + // already fetched in the batch. const posts = await Promise.all( - skeleton.order.map(async item => { - const rec = await getRecordByUri(item.post, undefined, signal).catch( - () => undefined, - ) - if (!rec) return undefined - return hydratePostFromRecord(item.post, rec, signal, { - counts: true, - viewerDid: opts.viewerDid, - }).catch(() => undefined) + batch.order.map(item => { + const rec = batch.records.get(item.post) + return rec + ? hydratePostFromRecord(item.post, rec, signal, { + counts: true, + viewerDid: opts.viewerDid, + }).catch(() => undefined) + : Promise.resolve(undefined) }), ) return { - cursor: skeleton.cursor, + cursor: batch.cursor, feed: posts .filter((p): p is AppBskyFeedDefs.PostView => !!p) .map(post => ({post})), -- knotmirror 0.0.1