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. The services are pure transport (no React imports); TanStack Query sits on top of them and owns the cache — see Data layer.

Files at a glance

File Role
utils/api.ts API_URL, WS_URL, WEB_URL constants; handleResponse; emitSessionExpired
utils/apiFetch.ts apiFetch(path, init?) — authenticated fetch with reactive 401 → refresh → retry; apiJson(path, init?)apiFetch + handleResponse in one call
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 (pure transport, owns enum unions)
lib/queryClient.ts TanStack Query client singleton + default options
hooks/use*.ts One query hook per cacheable resource + its <thing>QueryKey

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 these at build time (https://api.tomoda.life/api/v1, wss://api.tomoda.life/ws): the web bundle via Cloud Build --build-arg substitutions (cloudbuild-frontend.yaml/frontend/Dockerfile), native binaries from frontend/.env + frontend/scripts/build-with-secrets.sh.

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), throws a SessionExpiredError so the root layout's unhandled-rejection handler swallows it. It does not emit AUTH_SESSION_EXPIRED itself: a 401 only reaches handleResponse after apiFetch has already tried a refresh + retry and emitted on give-up (see Refresh on 401).
  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

Authenticated service calls go through apiJson(path, init?), the apiFetch + handleResponse shape nearly every service method wants. apiFetch injects the bearer + JSON headers (merged over any caller headers) and owns the reactive refresh (see Refresh on 401); apiJson funnels its Response through handleResponse and returns the typed body. A typical call:

return apiJson<Chat[]>('/chats');

path is relative to API_URL (apiFetch prepends it), or a full URL. Reach for apiFetch directly only when the caller inspects the raw Response (res.status / res.ok), then funnel it through handleResponse by hand. Intentionally unauthenticated calls (login, register, refresh, OTP, and public projections like /events/:id/public) stay on a direct fetch with no auth header, since a 401 there is a credential error, not a stale session.

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 is a proactive top-up; apiFetch is the reactive safety net for abrupt expiry (clock skew, server-side revocation, a request fired right after a backgrounded resume). On a refreshable 401 apiFetch:

  1. Confirms the request isn't an auth endpoint (/auth/* is excluded — a 401 there is a wrong credential, not a stale token) and hasn't already retried.
  2. Runs a single-flight refresh: concurrent 401s dedupe onto one in-flight authService.refreshSession(refreshToken) (pure, no React), persists the new access token to tokenStore + setCachedToken, and…
  3. Retries the original request once with the new token.

Only when refresh gives up — no refresh token, the server rejects the refresh, or the retried request also 401s — does apiFetch emit AUTH_SESSION_EXPIRED, which AuthContext turns into a logout. A successful refresh + retry is invisible to the caller; the user is never bounced to /auth/login.

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
reactive refresh on 401
Backend 401 TOKEN_EXPIRED
↓ apiFetch single-flight
refreshSession → new access token
↓ retry once
Backend 200 (recovered)
↓ else give up
emit AUTH_SESSION_EXPIRED → logout
Three 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 triggers apiFetch's single-flight refresh + one retry, and only a failed refresh (no token, rejected, or a second 401) emits the debounced AUTH_SESSION_EXPIRED that logs the user out.

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), profile booklet pages /discovery/profiles/:id/{journal,atlas,atlas/:country,atlas/:country/regions} (getUserJournal is cursor-paginated trip/day/single segments of place-stops; getAtlasRegions returns a country's admin-1 provinces with GeoJSON boundaries, fetched on demand per country for the map drill), getFeed(lat,lng)/discovery/feed (home feed: moments tray + events). 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
activityService.ts activity Client interaction-logging framework. Transport buffers events and batch-POSTs /activity/events (flush on a 10s idle timer, at 100 events, or on app backgrounding; best-effort, per-verb sampling). tracker(surface) returns typed helpers (impression/select/view/dwell/tap/dismiss/search) so a screen logs without repeating the surface. Reusable hook: useImpressionTracker (spread onto a FlatList/FlashList to log the shown-but-not-picked negatives with rank on viewability, e.g. the search results list)
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.

Data layer

The services above are the transport. The cache, dedup, and revalidation on top of them are TanStack Query (@tanstack/react-query). Route files don't call services directly for reads; they consume a query hook.

  • Client singleton: frontend/lib/queryClient.ts. Defaults: staleTime: 30_000, gcTime: 5 * 60_000, retry: 2, refetchOnWindowFocus: false. QueryClientProvider mounts outermost in app/_layout.tsx. A TUNING NOTE in that file names the three levers (per-query staleTime, WS invalidateQueries, useRefetchOnFocus) to reach for before touching the global defaults.
  • One hook per cacheable resource in frontend/hooks/ (useMoment, usePlanDetail, useEventDetail, useUserProfile, useStashItems, …), each exporting its own <thing>QueryKey tuple factory next to it. There is no central query-key file — invalidation sites import the key name from the domain hook.
  • Reads are keyed by the service call; the queryFn is the service function. refetchOnWindowFocus is off (React Native has no window-focus event); screen-focus refetch is opt-in via hooks/useRefetchOnFocus.ts.
  • WebSocket-driven freshness: socket handlers invalidateQueries or setQueryData instead of every consumer refetching. A WS-hydrated query (chat-unread, friends, taxonomy) carries the authoritative value via setQueryData, runs staleTime: Infinity, and reconciles only on a reconnect gap. See State Management → Data layer.
  • Errors funnel to components/ui/AppErrorBoundary.tsx (QueryErrorResetBoundary + themed, Sentry-reported Retry).

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 call resolves through handleResponse (directly, or via apiJson); 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.
  • Caching lives in Query, not the services: TanStack Query is the app-wide read cache and stale-while-revalidate layer (see Data layer). A few services (chatService, getChatRooms) additionally persist their own copy via storageService for offline-first cold starts; the realtime chat message stream stays outside Query by design.