OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android / lib / favela-api.js

108 lines · javascript ref:
// 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 })
}