diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index 293e1e4..2e27474 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -52,29 +52,32 @@ export class CustomFeedAPI implements FeedAPI { cursor: string | undefined limit: number }): Promise { - // 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. + // 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. if (MICROCOSM_ENABLED) { try { + const agent = this.agent const page = await buildCustomFeed(this.params.feed, { - viewerDid: this.agent.did, + viewerDid: agent.did, limit, 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 + 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, } } : undefined, diff --git a/src/lib/microcosm/feed.ts b/src/lib/microcosm/feed.ts index db14695..3dc5b81 100644 --- a/src/lib/microcosm/feed.ts +++ b/src/lib/microcosm/feed.ts @@ -149,13 +149,24 @@ 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. * - * 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. + * 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). */ export async function buildCustomFeed( feedUri: string, @@ -164,53 +175,55 @@ export async function buildCustomFeed( limit?: number cursor?: string /** - * 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. + * Fetch the feed skeleton for `feedGenDid`. Implemented by the caller via a + * PDS-proxied getFeedSkeleton (see above). Omit when logged out. */ - getAuthorization?: (feedGenDid: string) => Promise + getSkeleton?: (args: { + feedGenDid: string + feed: string + limit: number + cursor?: string + }) => Promise } = {}, signal?: AbortSignal, ): Promise { const feedGenDid = await resolveFeedGenDid(feedUri, signal) if (!feedGenDid) return {feed: []} - const authorization = opts.getAuthorization - ? await opts.getAuthorization(feedGenDid).catch(() => undefined) - : undefined + 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: []} - // 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. + // Hydrate each post uri: record + author via Slingshot (cached/deduped), plus + // counts + viewer state from Constellation. const posts = await Promise.all( - 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) + 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) }), ) return { - cursor: batch.cursor, + cursor: skeleton.cursor, feed: posts .filter((p): p is AppBskyFeedDefs.PostView => !!p) .map(post => ({post})),