Real-time¶
Real-time chat in Tomoda runs over a per-room WebSocket connection. The client lives entirely in frontend/services/chatService.ts as a singleton ChatService instance.
Connection URL¶
The base WebSocket URL comes from EXPO_PUBLIC_WS_URL (see API Client):
| Environment | URL |
|---|---|
| Production | wss://api.tomoda.life/ws |
| Local (web / iOS sim) | ws://127.0.0.1:8080/ws |
| Local (Android emulator) | ws://10.0.2.2:8080/ws |
Per-room URL pattern:
${WS_URL}/chat/${chatId}?token=${jwt}
The JWT is passed as a query parameter because the WebSocket constructor (both browser and React Native) does not accept custom headers. The backend authenticates the upgrade request from the query string.
Lifecycle¶
A single ChatService instance is exported and lives for the app's lifetime. It connects when a chat room is opened and disconnects when the user leaves.
import { chatService } from '@/services/chatService';
useEffect(() => {
chatService.connect(chatId);
const off = chatService.onMessage(handleMessage);
return () => { off(); chatService.disconnect(); };
}, [chatId]);
- Open: when the user enters a chat room screen (
/connect/chat/:id). - Close: when the screen unmounts;
disconnect()sends close code1000("Client disconnect"). - Heartbeat: while open, the client sends
{"type":"ping"}every 30 seconds and expectspong. - Reconnect: on abnormal close, exponential back-off (
1s, 2s, 4s, ...capped at 10s) for up to 5 attempts.
Inbound event types¶
ChatService.handleMessage() dispatches the following server-sent types:
| Type | Payload | Triggers |
|---|---|---|
new_message |
ChatMessage |
Render incoming message; append to local cache |
message_updated |
{ message_id, chat_id, content, edited_at } |
Replace in list, show "edited" hint |
message_deleted |
{ message_id, chat_id, new_last_message? } |
Remove message; update room's last-message preview |
messages_expired |
{ chat_id, message_ids[] } |
Remove disappearing messages whose TTL elapsed |
reaction_update |
{ message_id, chat_id, reactions: Record<emoji, userId[]> } |
Re-render reaction bar |
mark_read |
{ user_id, chat_id, last_read_at, ... } |
Update read receipts |
user_joined |
{ user_id, user_name, online_count } |
Presence indicator |
user_left |
{ user_id, online_count } |
Presence indicator |
pong |
— | Heartbeat ack (no UI effect) |
error |
{ message } |
Surface via errorCallbacks |
Outbound commands¶
The client sends framed JSON messages of the form { "type": "...", "data": {...} }:
| Type | Sent via | Body |
|---|---|---|
send_message |
sendMessage(content, replyToId?) |
{ chat_id, content, reply_to_id? } |
edit_message |
editMessage(id, content) |
{ message_id, content } |
delete_message |
deleteMessage(id) |
{ message_id } |
add_reaction |
sendReaction(id, emoji) |
{ message_id, emoji } |
ping |
heartbeat | — |
markRead(chatId) is a REST call (POST /chat/:id/read), not a WS message — it's idempotent and survives socket drops.
Close codes¶
Decoded in WS_CLOSE_CODES for diagnostics:
| Code | Meaning |
|---|---|
1000 |
Normal close (client disconnect) |
1001 |
Server going away |
1006 |
Connection lost (no close frame; network or crash) |
1008 |
Policy violation (likely bad token) |
1011 |
Server error |
4001 |
Unauthorized |
4003 |
Forbidden |
4004 |
Room not found |
The browser onerror callback is intentionally empty — the WebSocket spec hides error details from JS. The close code + reason is the canonical signal.
Platform notes¶
Both targets use the same code path — new WebSocket(url):
- On native (iOS / Android), Tomoda uses React Native's built-in WebSocket implementation.
- On web,
react-native-webre-exports the DOMWebSocket.
There is no platform branch in ChatService for transport.
Where it sits in the stack¶
sequenceDiagram
participant Screen as connect/chat/[id].tsx
participant Svc as chatService
participant BE as Backend WS Hub
Screen->>Svc: connect(chatId)
Svc->>BE: WS upgrade ?token=JWT
BE-->>Svc: open
Svc-->>Screen: onConnected()
loop while open
BE-->>Svc: { type: "new_message", data }
Svc-->>Screen: onMessage(cb)
Svc->>BE: { type: "ping" } (every 30s)
BE-->>Svc: { type: "pong" }
end
Screen->>Svc: disconnect()
Svc->>BE: close 1000
Cross-links¶
- API Client — REST counterpart and
WS_URLresolution - Backend WebSocket Hub — server-side implementation
- Real-time architecture — system-level real-time design