OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 367a3f9

api: one client for favela-web's HTTP surface, and the home feed uses it
The worklet now holds a session (46eedda/6162368) but nothing was sending it: the timeline screen called fetch(favelaUrl + '/api/timeline') directly, with no cookie. favela-web answers that 200 with viewerDid null, so the rows come back without liked/reposted state and without per-viewer mutes — and nothing errors. The bug is invisible from the UI, which is why the cookie is asserted in tests rather than left to a device. lib/favela-api.js is now the ONE place that knows the URL shape and how the session is carried, the same role lib/ipc-client.js plays for the IPC wire. No screen may call fetch(favelaUrl + …) directly; two definitions of the request is how the session silently goes missing from one of them. Cookie is set explicitly rather than relying on a jar: bare-fetch and RN's fetch differ there, and depending on either would be a per-platform difference nobody would notice until one surface lost its viewer. Timeline now routes BOTH feeds (personal timeline and community author feed) through it. Community feeds previously fell back to the IPC verb even when favela-web was up, so the two feeds disagreed about which index they read. Also fixes a render loop introduced in the first cut of this change: apiFromStatus was called inline in render, so `api` was a new object every render and, being a dependency of Timeline's load callback, would have re-armed it and re-fetched forever. Memoised on `status`. Verified: 101 assertions green across adopt + ipc + session + favela-api, and the new test asserts ALL NINE typed wrappers send the cookie — the regression that matters is a wrapper added later that quietly forgets it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
367a3f98055f5752180f5dda5accd4fae68bf0b9
by plankatron <fleeky@gmail.com> · 2026-08-11T18:00:14.000Z

Changed files (4) +154 -9

app/App.jsx +13 -8
……@@ -7,12 +7,13 @@
77// ⛔ NO ATPROTO KNOWLEDGE LIVES HERE. No CAR parsing, no MST, no signing, no record shapes beyond what
88// the appview already hydrated. If a component ever needs atproto-repo, the boundary is in the wrong
99// place (spec/architecture.md §3).
10import React, { useEffect, useState, useCallback } from 'react'
10import React, { useEffect, useState, useCallback, useMemo } from 'react'
1111import { View, Text, FlatList, RefreshControl, ActivityIndicator, StyleSheet, Pressable, SafeAreaView, BackHandler } from 'react-native'
1212import { startNode, stopNode } from './worklet'
1313import Pair from './Pair'
1414import Post from './Post'
1515import { Thread, Profile, Search, Compose, Settings, Invite, Identities, Communities, People, Header, Center } from './Screens'
16import { apiFromStatus } from '../lib/favela-api'
1617
1718export default function App () {
1819 const [client, setClient] = useState(null)
……@@ -69,6 +70,9 @@ function Shell ({ client, status, onPaired }) {
6970 return () => { live = false }
7071 }, [client])
7172 const acting = ids.dids.find((d) => d.did === actingDid) || ids.dids.find((d) => d.primary) || null
73 // MEMOISED, not built in render: `api` is a dependency of Timeline's load callback, so a fresh
74 // instance every render would re-arm the callback every render and re-fetch forever.
75 const api = useMemo(() => apiFromStatus(status), [status])
7276 const nav = {
7377 push: (screen) => setStack((st) => [...st, screen]),
7478 pop: () => setStack((st) => st.slice(0, -1))
……@@ -118,7 +122,7 @@ function Shell ({ client, status, onPaired }) {
118122 </Pressable>
119123 : null}
120124 <View style={s.fill}>
121 {tab === 'home' ? <Timeline client={client} nav={nav} acting={acting} favelaUrl={status && status.favelaUrl} /> : null}
125 {tab === 'home' ? <Timeline client={client} nav={nav} acting={acting} api={api} /> : null}
122126 {tab === 'search' ? <Search client={client} nav={nav} /> : null}
123127 {tab === 'browse' ? <Communities client={client} nav={nav} /> : null}
124128 {tab === 'settings' ? <Settings client={client} status={status} nav={nav} /> : null}
……@@ -141,7 +145,7 @@ function Shell ({ client, status, onPaired }) {
141145//
142146// The fallback is deliberate and temporary: if favela-web did not start, the screen still works on the
143147// legacy surface rather than being blank, and the log says which one is in use.
144function Timeline ({ client, nav, acting, favelaUrl }) {
148function Timeline ({ client, nav, acting, api }) {
145149 const [posts, setPosts] = useState([])
146150 const [cursor, setCursor] = useState(null)
147151 const [refreshing, setRefreshing] = useState(false)
……@@ -154,10 +158,11 @@ function Timeline ({ client, nav, acting, favelaUrl }) {
154158 const load = useCallback(async (reset) => {
155159 try {
156160 let r
157 if (favelaUrl && !community) {
158 const res = await fetch(favelaUrl + '/api/timeline?limit=30')
159 if (!res.ok) throw new Error('favela-web /api/timeline ' + res.status)
160 r = await res.json()
161 if (api) {
162 // Both feeds now come from favela-web, through the ONE client that carries the session — a raw
163 // fetch here would silently drop the cookie and the rows would come back without liked/reposted
164 // state, with no error to notice.
165 r = community ? await api.author({ did: community, limit: 30 }) : await api.timeline({ limit: 30 })
161166 } else {
162167 r = community
163168 ? await client.author({ did: community, limit: 30, cursor: reset ? null : cursor })
……@@ -167,7 +172,7 @@ function Timeline ({ client, nav, acting, favelaUrl }) {
167172 setCursor(r.cursor)
168173 setErr(null)
169174 } catch (e) { setErr(e.message) }
170 }, [client, cursor, community, favelaUrl])
175 }, [client, cursor, community, api])
171176
172177 // Reload when the identity changes — otherwise switching shows the previous identity's feed.
173178 useEffect(() => { setPosts([]); setCursor(null); load(true) }, [community]) // eslint-disable-line react-hooks/exhaustive-deps
lib/favela-api.js +76 -0
……@@ -0,0 +1,76 @@
1// The client for favela-web's HTTP API — the ONE place that knows the URL shape and how the session
2// is carried.
3//
4// ⛔ NO SCREEN MAY CALL fetch(favelaUrl + …) DIRECTLY. The moment two components build their own
5// request the session handling has two definitions, and the one that forgets the cookie degrades
6// SILENTLY: favela-web answers 200 with viewerDid null, so the posts render un-liked, un-reposted and
7// unmuted, and nothing anywhere says why. That is the same "two definitions drift apart" failure the
8// IPC client exists to prevent (see lib/ipc-client.js), one layer out.
9//
10// The session is a cookie because favela-web is a web server and the browser client is its first
11// consumer. There is no cookie jar here, so we set the header explicitly — bare-fetch and RN's fetch
12// differ on jar behaviour and relying on either would be a silent per-platform difference.
13
14const DEFAULT_TIMEOUT = 30_000
15
16export class FavelaApi {
17 /**
18 * @param {object} opts
19 * @param {string} opts.baseUrl e.g. http://127.0.0.1:4041
20 * @param {string?} opts.session favela_session token, or null for anonymous
21 * @param {Function} [opts.fetchImpl] defaults to globalThis.fetch
22 * @param {number} [opts.timeout]
23 */
24 constructor ({ baseUrl, session = null, fetchImpl = null, timeout = DEFAULT_TIMEOUT } = {}) {
25 if (!baseUrl) throw new Error('FavelaApi requires baseUrl')
26 this.baseUrl = String(baseUrl).replace(/\/+$/, '')
27 this.session = session
28 this._fetch = fetchImpl || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null)
29 if (!this._fetch) throw new Error('FavelaApi: no fetch available')
30 this._timeout = timeout
31 }
32
33 /** True when requests carry a viewer. A screen that renders viewer-dependent UI should check this. */
34 get authenticated () { return !!this.session }
35
36 url (path, params) {
37 const qs = params
38 ? Object.entries(params)
39 .filter(([, v]) => v !== undefined && v !== null && v !== '')
40 .map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(String(v)))
41 .join('&')
42 : ''
43 return this.baseUrl + path + (qs ? '?' + qs : '')
44 }
45
46 async get (path, params) {
47 const headers = {}
48 // Explicit, not jar-dependent — see the header note above.
49 if (this.session) headers.cookie = 'favela_session=' + this.session
50 const res = await this._fetch(this.url(path, params), { headers })
51 let body = null
52 try { body = await res.json() } catch { /* non-JSON error page — fall through to status */ }
53 if (!res.ok) {
54 // Name the ROUTE. "HTTP 400" alone sends the reader to the wrong screen.
55 throw new Error('favela-web ' + path + ' → ' + res.status + (body && body.error ? ': ' + body.error : ''))
56 }
57 return body
58 }
59
60 // Typed wrappers. Thin on purpose — they exist so a screen never types a path, not to add logic.
61 timeline ({ limit = 30 } = {}) { return this.get('/api/timeline', { limit }) }
62 author ({ did, limit = 30 } = {}) { return this.get('/api/author', { did, limit }) }
63 profile ({ did } = {}) { return this.get('/api/profile', { did }) }
64 thread ({ uri, depth, parentHeight } = {}) { return this.get('/api/thread', { uri, depth, parentHeight }) }
65 replies ({ uri, limit = 50 } = {}) { return this.get('/api/replies', { uri, limit }) }
66 search ({ q, kind = 'posts', limit = 25 } = {}) { return this.get('/api/search', { q, kind, limit }) }
67 follows ({ did } = {}) { return this.get('/api/follows', { did }) }
68 followers ({ did } = {}) { return this.get('/api/followers', { did }) }
69 tagFeed ({ tag, limit = 30 } = {}) { return this.get('/api/feed', { tag, limit }) }
70}
71
72/** Build an API client from the worklet's `status`, or null if favela-web is not up. */
73export function apiFromStatus (status, fetchImpl = null) {
74 if (!status || !status.favelaUrl) return null
75 return new FavelaApi({ baseUrl: status.favelaUrl, session: status.favelaSession || null, fetchImpl })
76}
package.json +1 -1
……@@ -9,7 +9,7 @@
99 "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --defer pgh-runtime --out android/app/src/main/assets/worklet.bundle lib/node.js",
1010 "stage:addons": "node scripts/stage-addons.mjs",
1111 "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run stage:addons",
12 "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs",
12 "test": "bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs",
1313 "stamp:worklet": "node -e \"const fs=require('fs');const p='android/app/src/main/assets/worklet.bundle';fs.writeFileSync(p+'.size',String(fs.statSync(p).size))\""
1414 },
1515 "dependencies": {
test/favela-api.test.mjs +64 -0
……@@ -0,0 +1,64 @@
1// The favela-web HTTP client. Pure — a fake fetch records what would go on the wire.
2//
3// The claim under test is narrow and load-bearing: every request carries the session cookie when one
4// exists. A client that quietly omits it still gets 200s back, with viewerDid null — so the bug shows
5// up as posts rendering un-liked, never as an error. That is exactly why it is asserted here rather
6// than left to a device.
7//
8// Run: bare test/favela-api.test.mjs
9import { FavelaApi, apiFromStatus } from '../lib/favela-api.js'
10
11const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
12
13const calls = []
14const fakeFetch = async (url, init) => {
15 calls.push({ url, init })
16 return { ok: true, status: 200, json: async () => ({ posts: [], url }) }
17}
18
19console.log('url building')
20const api = new FavelaApi({ baseUrl: 'http://127.0.0.1:4041/', session: 'tok-1', fetchImpl: fakeFetch })
21assert(api.baseUrl === 'http://127.0.0.1:4041', 'trailing slash trimmed (else //api/…)')
22assert(api.url('/api/timeline', { limit: 30 }) === 'http://127.0.0.1:4041/api/timeline?limit=30', 'query string built')
23assert(api.url('/api/thread', { uri: 'at://x/y/z' }).includes('uri=at%3A%2F%2Fx%2Fy%2Fz'), 'params are URL-ENCODED (an at:// uri must survive)')
24assert(api.url('/api/search', { q: 'a', kind: null, limit: undefined }) === 'http://127.0.0.1:4041/api/search?q=a', 'null/undefined params dropped, not sent as "null"')
25
26console.log('session')
27assert(api.authenticated === true, 'reports authenticated when it holds a token')
28await api.timeline({ limit: 5 })
29assert(calls[0].init.headers.cookie === 'favela_session=tok-1', 'sends the session cookie')
30
31const anon = new FavelaApi({ baseUrl: 'http://x', fetchImpl: fakeFetch })
32assert(anon.authenticated === false, 'reports anonymous without a token')
33await anon.timeline()
34assert(!calls[1].init.headers.cookie, 'sends NO cookie when anonymous (rather than an empty one)')
35
36console.log('every typed wrapper carries the session')
37// The regression that matters: a wrapper added later that forgets the cookie. Assert across ALL of them
38// rather than trusting the one that happened to be tested.
39calls.length = 0
40await api.timeline(); await api.author({ did: 'd' }); await api.profile({ did: 'd' })
41await api.thread({ uri: 'u' }); await api.replies({ uri: 'u' }); await api.search({ q: 'x' })
42await api.follows({ did: 'd' }); await api.followers({ did: 'd' }); await api.tagFeed({ tag: 't' })
43assert(calls.length === 9, 'nine wrappers exercised')
44assert(calls.every((c) => c.init.headers.cookie === 'favela_session=tok-1'), 'ALL nine send the cookie')
45assert(calls.map((c) => c.url).every((u) => u.startsWith('http://127.0.0.1:4041/api/')), 'all hit /api/')
46
47console.log('errors name the route')
48const failing = new FavelaApi({
49 baseUrl: 'http://x',
50 fetchImpl: async () => ({ ok: false, status: 400, json: async () => ({ error: 'uri required' }) })
51})
52let e = null
53try { await failing.thread({ uri: '' }) } catch (err) { e = err }
54assert(e && /\/api\/thread/.test(e.message) && /uri required/.test(e.message), 'error names the route AND the reason')
55
56console.log('apiFromStatus')
57assert(apiFromStatus(null) === null, 'null when there is no status')
58assert(apiFromStatus({ favelaUrl: null }) === null, 'null when favela-web did not start')
59const built = apiFromStatus({ favelaUrl: 'http://127.0.0.1:4041', favelaSession: 'tok-2' }, fakeFetch)
60assert(built.authenticated && built.session === 'tok-2', 'carries the session through from status')
61const anonBuilt = apiFromStatus({ favelaUrl: 'http://127.0.0.1:4041', favelaSession: null }, fakeFetch)
62assert(anonBuilt.authenticated === false, 'anonymous when sign-in failed — the screen can SAY so')
63
64console.log('\nall favela-api tests passed')