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}/chats`, {
  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 disk:

let _cachedToken: string | null = null;

export const getCachedToken = async () => {
  if (_cachedToken !== null) return _cachedToken;
  _cachedToken = await getAccessToken(); // utils/tokenStore.ts
  return _cachedToken;
};

AuthContext keeps the cache in sync via setCachedToken() / clearCachedToken() on every login mutation. Persistence funnels through utils/tokenStore.ts, the single seam for the access + refresh token, so the OS-share handler can later read them from a shared Keychain access group.

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

Screen
getThing()
happy path · per request
services/foo.ts
↓ getCachedToken()
tokenManager in-memory token
↓ Authorization: Bearer
Backend
↓ 200 + body
utils/api.ts handleResponse
4-min refresh loop
tokenManager isTokenExpiringSoon()
↓ POST /auth/refresh
Backend
↓ access_token
AuthContext setToken + setCachedToken
on 401 instead
Backend 401 TOKEN_EXPIRED
utils/api.ts emit AUTH_SESSION_EXPIRED
AuthContext.logout()
↓ router.replace
/auth/login
Three independent loops share one token cache. The happy path hits memory only; the 4-min poll proactively swaps the access token before it expires; a 401 from any request short-circuits to logout via a debounced DeviceEventEmitter event — Tomoda does not retry the original request.

Service modules

frontend/services/ holds one module per backend domain (a few domains split across two files where the surface is large). Each is a flat collection of async functions that return the parsed body (or throw on failure).

Service Backend domain Endpoints / notable exports
adminService.ts admin /admin/* metrics, user/event moderation
chatService.ts chat /chats/* REST + the per-room WebSocket; chatService singleton. See Real-time
discoveryService.ts discovery /discovery/map, /discovery/radar, /discovery/locations/:id, /discovery/search (NL intent), /discovery/search/users, /discovery/profiles/:id (full profile card). Event/moment detail proxy to /events/:id/detail and /moments/:id
searchService.ts discovery + plan + location events()/locations() hit /discovery/search/{events,locations} (the latter takes a kinds allowlist); saved-area pin GET/PUT /discovery/pin; saveLocation posts /items/save-location (plan); resolvePlace posts /locations/resolve (location)
taxonomyService.ts discovery /discovery/taxonomy, /discovery/taxonomy/resolve
eventService.ts event events CRUD + RSVPs; event-items board via getEventItems / createEventItem / updateEventItem / deleteEventItem / reorderEventItems on /events/:id/items. EventItemInput.photo_keys?: string[] carries uploaded asset keys (cover first); a list replaces the item's photos, omit on update to preserve existing ones
eventCheckinService.ts event /events/active/nearby, POST /events/:id/checkin (arrival proximity match)
friendService.ts friend /friends/* graph, requests, close-friends; user search proxies to /discovery/search/users
planService.ts plan /plans/* canvas, poll, members, promote; /users/:userId/plans
itemService.ts plan /items/* stash + plan board items
momentService.ts moment /moments/* ephemeral posts
checkinService.ts presence /checkins, /checkins/me, /checkins/recent, getCheckinDetail(id) (chat link-preview CheckinCard), untagCompanion(id, 'me' \| userId)
locationService.ts location + presence searchNearbyLocations(lat, lng, radius=150)/locations/nearby; autocompleteLocations(query, lat?, lng?, limit=5, kinds: LocationKind[]=['poi'])/locations/nearby/autocomplete (the kinds allowlist defaults to ['poi']; add settlement/address/residential to widen); resolveLocation(candidate)/locations/resolve; reverseCity(lat, lng)/locations/reverse; plus /location/update, /location/friends, /presence/* active-location + heartbeat
notificationService.ts notification /notifications/* inbox, /push-tokens device registration
partnerService.ts partner /partner/*, /partners/*, /me/* partner membership
linkPreviewService.ts media /link-preview URL unfurl
klipyService.ts (external provider) /klipy/* GIF/sticker search
presignedUpload.ts media uploadViaPresign runs POST /uploads/<kind> → PUT for avatar, group-avatar, chat-image, moment, item-image, event-cover, plan-cover. uploadChatImagesBatch(uris, roomId) runs POST /uploads/chat-image/batch then PUTs in parallel
userService.ts user lean GET /users/:id (mention chips), /auth/profile read/update, avatar rotate/remove
storageService.ts (local) save(key, val) / load(key) + StorageKeys local cache

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

Lean vs full user fetch

Fetching a user comes in two shapes:

Need Call Shape
Mention chip, live-handle URL, quick label userService.resolveMention(id)GET /users/:id { id, name, username, avatar_url, bio, is_friend }
Full profile card (moments, events, mutuals, friendship state) discoveryService.getUserProfile(id)GET /discovery/profiles/:id aggregated UserProfile (optional auth; viewer-relative fields when authed)

The lean card is the hot path for chat and composer chips; userService memoizes it per id for five minutes and shares one in-flight fetch. The aggregated profile lives in the discovery domain because it joins across moments, events, and the friend graph.

Conventions

  • Auth header: Authorization: Bearer ${token} only — no cookies.
  • Content-Type: application/json on POST / PATCH / PUT. Asset uploads (avatar, group avatar, chat image, moment media, board-item photos, event cover, plan cover) go through presignedUpload.ts. POST /uploads/{kind} returns a 5-minute presigned PUT URL with Content-Type AND Content-Length signed in, the client PUTs the bytes directly to S3, then finalises on the matching resource endpoint (PUT /users/me/avatar, PUT /chats/{id}/avatar, POST /moments, POST /items with image_keys, or the chat WebSocket image_keys array). On native, PUT goes through expo-file-system's uploadAsync (RN fetch with a Blob from a file:// URI silently fails on iOS NSURLSession).
  • Errors: every service awaits handleResponse(response); never branch on response.ok manually.
  • Token reads: always await getCachedToken() on the hot path; persistence goes through utils/tokenStore.ts, 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.