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}/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¶
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/jsononPOST/PATCH/PUT. Asset uploads (avatar, group avatar, chat image, moment media, board-item photos, event cover, plan cover) go throughpresignedUpload.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 /itemswithimage_keys, or the chat WebSocketimage_keysarray). On native, PUT goes throughexpo-file-system'suploadAsync(RN fetch with a Blob from afile://URI silently fails on iOS NSURLSession). - Errors: every service awaits
handleResponse(response); never branch onresponse.okmanually. - Token reads: always
await getCachedToken()on the hot path; persistence goes throughutils/tokenStore.ts, 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