Skip to content

API Client

Tomoda has no HTTP library — every network call uses the platform's built-in fetch. utils/api.ts provides a tiny error-handling wrapper, utils/tokenManager.ts keeps the bearer token in-memory, and services/*.ts modules expose typed functions, one per backend domain.

Files at a glance

File Role
utils/api.ts API_URL, WS_URL, WEB_URL constants; handleResponse
utils/tokenManager.ts In-memory token cache + 4-minute refresh loop
contexts/AuthContext.tsx Owns the bearer; consumes AUTH_SESSION_EXPIRED
services/*.ts One module per backend domain

URL resolution

utils/api.ts resolves URLs lazily so dev / prod / per-platform overrides work without rebuilding:

const getApiUrl = () => {
  const apiUrl = process.env.EXPO_PUBLIC_API_URL;
  if (apiUrl && apiUrl.trim() !== '') return apiUrl;
  return 'http://127.0.0.1:8080/api/v1';
};

export const API_URL = getApiUrl();
export const WS_URL  = getWsUrl();    // ws://127.0.0.1:8080/ws by default
export const WEB_URL = process.env.EXPO_PUBLIC_WEB_URL?.replace(/\/$/, '')
  || 'https://tomoda.life';

Production injects values via eas.json's production profile (https://api.tomoda.life/api/v1, wss://api.tomoda.life/ws).

handleResponse

Every service call funnels its Response through handleResponse. It:

  1. Parses an error body on non-2xx and surfaces the server's error.message (or falls back to HTTP <status>).
  2. On 401 with a token-related error code (TOKEN_EXPIRED, TOKEN_INVALID, AUTH_REQUIRED, or no code at all), emits AUTH_SESSION_EXPIRED on the React Native DeviceEventEmitter.
  3. Reads the body as text first — returns null for empty responses, otherwise parses JSON (falling back to raw text for non-JSON payloads).
const emitSessionExpired = () => {
  const now = Date.now();
  if (now - _sessionExpiredAt < 2000) return; // 2s debounce
  _sessionExpiredAt = now;
  DeviceEventEmitter.emit(AUTH_SESSION_EXPIRED);
};

A 2-second debounce prevents a burst of failing requests from re-emitting the event repeatedly while logout is in flight.

AuthContext listens for AUTH_SESSION_EXPIRED and calls logout():

const subscription = DeviceEventEmitter.addListener(
  AUTH_SESSION_EXPIRED,
  () => { logout(); }
);

Building requests

Service modules build requests by hand — typically:

const token = await getCachedToken();
const response = await fetch(`${API_URL}/dm/rooms`, {
  headers: { 'Authorization': `Bearer ${token}` },
});
return handleResponse(response);

There is no canonical getHeaders() helper exported from utils/api.ts; each service inlines the header object for clarity (the auth header pattern is the same everywhere).

Token refresh

utils/tokenManager.ts keeps the access token in module-level memory so the hot path (every request) skips AsyncStorage:

let _cachedToken: string | null = null;

export const getCachedToken = async () => {
  if (_cachedToken !== null) return _cachedToken;
  _cachedToken = await AsyncStorage.getItem('@auth_token');
  return _cachedToken;
};

AuthContext keeps the cache in sync via setCachedToken() / clearCachedToken() on every login mutation.

Refresh is interval-driven, not request-driven:

// setupTokenRefresh() — runs while token + refreshToken are present
const interval = setInterval(async () => {
  const shouldRefresh = await isTokenExpiringSoon(); // < 5 min remaining
  if (shouldRefresh) await guardedRefresh();
}, 4 * 60 * 1000);                                   // every 4 min

A mutex guards concurrent refreshes — if two callers race, both share the in-flight promise:

let _refreshPromise: Promise<boolean> | null = null;
const guardedRefresh = async () => {
  if (_refreshPromise) return _refreshPromise;
  _refreshPromise = refreshCallback().finally(() => { _refreshPromise = null; });
  return _refreshPromise;
};

isTokenExpiringSoon() decodes the JWT with jwt-decode and returns true when exp is within 5 minutes.

Refresh on 401

The 4-minute poller covers the steady state. For abrupt expiry (clock skew, server-side revocation) the 401 → AUTH_SESSION_EXPIRED → logout path kicks in. Tomoda does not retry the original request after refresh — the user is bounced to /auth/login and resubmits.

Sequence — a single request with auto-refresh

sequenceDiagram
    autonumber
    participant UI as Screen
    participant Svc as services/foo.ts
    participant TM as tokenManager
    participant API as utils/api.ts
    participant BE as Backend
    participant Auth as AuthContext

    UI->>Svc: getThing()
    Svc->>TM: getCachedToken()
    TM-->>Svc: token (from memory)
    Svc->>BE: GET /foo Authorization: Bearer
    BE-->>Svc: 200 OK + body
    Svc->>API: handleResponse(res)
    API-->>UI: JSON

    Note over TM,Auth: ── 4-min interval poll ──
    TM->>TM: isTokenExpiringSoon() = true
    TM->>BE: POST /auth/refresh {refresh_token}
    BE-->>TM: { access_token }
    TM->>Auth: setToken(new)
    Auth->>TM: setCachedToken(new)

    Note over UI,Auth: ── on 401 instead ──
    BE-->>Svc: 401 TOKEN_EXPIRED
    API->>API: emit AUTH_SESSION_EXPIRED
    API-->>Svc: throw Error
    Auth-->>UI: logout(); router → /auth/login

Service modules

frontend/services/ has one module per backend domain. Each is a flat collection of async functions that return the parsed body (or throw on failure).

Service Domain Notable exports
adminService.ts Admin tools metrics, user/event moderation
chatService.ts DMs + group chat (HTTP + WS) chatService singleton, REST helpers, WebSocket lifecycle
discoveryService.ts Discover feed / map data nearby events, user search
eventService.ts Events CRUD + RSVPs create/edit/delete event
friendService.ts Friend graph list, requests, accept/decline
klipyService.ts GIF/sticker provider search Klipy library
linkPreviewService.ts URL unfurl previews fetch OG metadata
locationService.ts Server-side location sharing location-share toggles
storageService.ts Local storage abstraction save(key, val) / load(key) + StorageKeys
userService.ts User profile / search get user, search users

chatService.ts is the largest — it owns the WebSocket lifecycle in addition to REST. See Real-time.

Conventions

  • Auth header: Authorization: Bearer ${token} only — no cookies.
  • Content-Type: application/json on POST / PATCH / PUT; multipart FormData for avatars.
  • Errors: every service awaits handleResponse(response); never branch on response.ok manually.
  • Token reads: always await getCachedToken(), never AsyncStorage.getItem('@auth_token') directly.
  • No client-side caching layer: a few services (chatService, getChatRooms) keep their own cache via storageService for offline-first UX, but there's no app-wide cache or stale-while-revalidate.