// The client for favela-web's HTTP API — the ONE place that knows the URL shape and how the session
// is carried.
//
// ⛔ NO SCREEN MAY CALL fetch(favelaUrl + …) DIRECTLY. The moment two components build their own
// request the session handling has two definitions, and the one that forgets the cookie degrades
// SILENTLY: favela-web answers 200 with viewerDid null, so the posts render un-liked, un-reposted and
// unmuted, and nothing anywhere says why. That is the same "two definitions drift apart" failure the
// IPC client exists to prevent (see lib/ipc-client.js), one layer out.
//
// The session is a cookie because favela-web is a web server and the browser client is its first
// consumer. There is no cookie jar here, so we set the header explicitly — bare-fetch and RN's fetch
// differ on jar behaviour and relying on either would be a silent per-platform difference.
const DEFAULT_TIMEOUT = 30_000
export class FavelaApi {
/**
* @param {object} opts
* @param {string} opts.baseUrl e.g. http://127.0.0.1:4041
* @param {string?} opts.session favela_session token, or null for anonymous
* @param {Function} [opts.fetchImpl] defaults to globalThis.fetch
* @param {number} [opts.timeout]
*/
constructor ({ baseUrl, session = null, fetchImpl = null, timeout = DEFAULT_TIMEOUT } = {}) {
if (!baseUrl) throw new Error('FavelaApi requires baseUrl')
this.baseUrl = String(baseUrl).replace(/\/+$/, '')
this.session = session
this._fetch = fetchImpl || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null)
if (!this._fetch) throw new Error('FavelaApi: no fetch available')
this._timeout = timeout
}
/** True when requests carry a viewer. A screen that renders viewer-dependent UI should check this. */
get authenticated () { return !!this.session }
url (path, params) {
const qs = params
? Object.entries(params)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(String(v)))
.join('&')
: ''
return this.baseUrl + path + (qs ? '?' + qs : '')
}
async get (path, params) {
const headers = {}
// Explicit, not jar-dependent — see the header note above.
if (this.session) headers.cookie = 'favela_session=' + this.session
const res = await this._fetch(this.url(path, params), { headers })
let body = null
try { body = await res.json() } catch { /* non-JSON error page — fall through to status */ }
if (!res.ok) {
// Name the ROUTE. "HTTP 400" alone sends the reader to the wrong screen.
throw new Error('favela-web ' + path + ' → ' + res.status + (body && body.error ? ': ' + body.error : ''))
}
return body
}
// The write half. Same session rule as `get` — and the same reason it lives here rather than at the
// call site: a POST that forgets the cookie does not fail, it gets a 401 with an `error` body, and the
// caller that hand-rolled it is the one least likely to check.
async post (path, body) {
const headers = { 'content-type': 'application/json' }
if (this.session) headers.cookie = 'favela_session=' + this.session
const res = await this._fetch(this.url(path), { method: 'POST', headers, body: JSON.stringify(body || {}) })
let out = null
try { out = await res.json() } catch { /* non-JSON error page — fall through to status */ }
if (!res.ok) throw new Error('favela-web ' + path + ' → ' + res.status + (out && out.error ? ': ' + out.error : ''))
return out
}
// Typed wrappers. Thin on purpose — they exist so a screen never types a path, not to add logic.
timeline ({ limit = 30 } = {}) { return this.get('/api/timeline', { limit }) }
author ({ did, limit = 30 } = {}) { return this.get('/api/author', { did, limit }) }
profile ({ did } = {}) { return this.get('/api/profile', { did }) }
thread ({ uri, depth, parentHeight } = {}) { return this.get('/api/thread', { uri, depth, parentHeight }) }
replies ({ uri, limit = 50 } = {}) { return this.get('/api/replies', { uri, limit }) }
search ({ q, kind = 'posts', limit = 25 } = {}) { return this.get('/api/search', { q, kind, limit }) }
follows ({ did } = {}) { return this.get('/api/follows', { did }) }
followers ({ did } = {}) { return this.get('/api/followers', { did }) }
// hydrate=1 — profiles included, so a list of PEOPLE is one request rather than N+1. The bare-list
// form above stays for callers that only need DIDs and would discard the hydration.
people ({ did, kind = 'follows', limit = 200 } = {}) {
return this.get(kind === 'followers' ? '/api/followers' : '/api/follows', { did, limit, hydrate: 1 })
}
tagFeed ({ tag, limit = 30 } = {}) { return this.get('/api/feed', { tag, limit }) }
// Subscribe to a DID: favela-web resolves its verifyKey, joins its topic and follows the freshest
// VERIFIED co-owner's firehose. Idempotent — addPeer returns the existing peer for a DID already read.
//
// ⛔ THIS IS THE ONLY WAY THE PHONE SHOULD INDEX ANYONE. The worklet used to keep its own appview and
// fold records into it straight off local disk, which skipped every check this route performs and fed
// an index no screen reads. A DID favela-web hosts takes addPeer's coHosted branch and baselines from
// the shared store rather than the network, so `track` is right for our own DIDs too.
track ({ did, wait } = {}) { return this.post('/api/track', { did, ...(wait ? { wait } : {}) }) }
// Follow/unfollow — a TOGGLE, and favela-web owns both halves: it reads the existing follow rkey, asks
// hpp to write or delete the record, and updates its own index. Nothing here decides anything.
follow ({ did } = {}) { return this.post('/api/follow', { did }) }
}
/** Build an API client from the worklet's `status`, or null if favela-web is not up. */
export function apiFromStatus (status, fetchImpl = null) {
if (!status || !status.favelaUrl) return null
return new FavelaApi({ baseUrl: status.favelaUrl, session: status.favelaSession || null, fetchImpl })
}