Skip to content

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 code 1000 ("Client disconnect").
  • Heartbeat: while open, the client sends {"type":"ping"} every 30 seconds and expects pong.
  • 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 pathnew WebSocket(url):

  • On native (iOS / Android), Tomoda uses React Native's built-in WebSocket implementation.
  • On web, react-native-web re-exports the DOM WebSocket.

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