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:
- Parses an error body on non-2xx and surfaces the server's
error.message(or falls back toHTTP <status>). - On 401 with a token-related error code (
TOKEN_EXPIRED,TOKEN_INVALID,AUTH_REQUIRED, or no code at all), emitsAUTH_SESSION_EXPIREDon the React NativeDeviceEventEmitter. - Reads the body as text first — returns
nullfor 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/jsononPOST/PATCH/PUT; multipartFormDatafor avatars. - Errors: every service awaits
handleResponse(response); never branch onresponse.okmanually. - Token reads: always
await getCachedToken(), neverAsyncStorage.getItem('@auth_token')directly. - No client-side caching layer: a few services (
chatService,getChatRooms) keep their own cache viastorageServicefor offline-first UX, but there's no app-wide cache or stale-while-revalidate.
Cross-links¶
- State Management —
AuthContextis where the bearer lives - Real-time — the WebSocket counterpart for chat
- Backend API conventions — server-side contract