How TREK Handles Real-Time Collaboration via WebSockets: Architecture & Implementation
TREK implements real-time collaboration via a lightweight, JWT-authenticated WebSocket layer that synchronizes trip data across clients using ephemeral tokens, trip-scoped rooms, and automatic reconnection with state recovery.
The open-source TREK application enables multiple users to simultaneously view and edit trip itineraries through a robust WebSocket architecture. This article examines the implementation details from the server-side socket management to the client-side synchronization layer, referencing actual source files from the mauriceboe/TREK repository.
Authentication and Handshake Flow
TREK uses a two-phase authentication process to establish secure WebSocket connections without exposing long-lived credentials.
Ephemeral Token Generation
Before opening a socket, the client requests a short-lived WS token via POST /api/auth/ws-token. In client/src/api/websocket.ts, the fetchWsToken function retrieves this token, which typically expires within minutes.
Server-Side Validation
The server validates the ephemeral token using consumeEphemeralTokenWithMeta within the setupWebSocket function in server/src/websocket.ts (lines 72-98). If validation fails—whether due to expiration, invalid signatures, or password version mismatches—the server immediately terminates the connection:
// server/src/websocket.ts (simplified)
if (!isValidToken) {
socket.close(4001, 'Authentication failed');
return;
}
Upon successful authentication, the server assigns a monotonically increasing socketId to each connection (lines 106-108) and sends a welcome message containing the identifier.
Socket Identification and Rate Limiting
To prevent abuse, TREK enforces per-socket rate limiting through a message counter system. The socketMsgCounts map tracks each socket's activity, enforcing a hard limit of WS_MSG_LIMIT = 30 messages per 10-second window. If a client exceeds this threshold, the server can throttle or disconnect the offending socket.
Room Management for Trip-Scoped Channels
TREK organizes WebSocket connections into rooms based on tripId, enabling targeted broadcasting to only relevant participants.
Room Structure
The server maintains a rooms Map that associates each tripId with a Set<socket> containing all active connections for that trip. This structure lives in server/src/websocket.ts and enables O(1) lookup for broadcast operations.
Joining and Leaving Rooms
When a user opens a trip in the UI, the client calls joinTrip(tripId), which sends a message with {type: 'join', tripId}. The server verifies access rights via canAccessTrip before adding the socket to the appropriate room (lines 42-54):
// Server-side room management
if (await canAccessTrip(userId, tripId)) {
rooms[tripId].add(socket);
}
When users navigate away or close the application, leaveTrip triggers the leaveRoom function (lines 56-60), automatically cleaning up the socket from the room set. The server also calls this cleanup in the socket close handler (lines 62-70) to prevent memory leaks.
Broadcasting Mechanisms
TREK provides two distinct broadcasting patterns to support different collaboration scenarios.
Trip-Wide Broadcasts
The broadcast(tripId, eventType, payload, excludeSid?) function iterates through all sockets in a specific trip room, sending JSON payloads to every participant. The optional excludeSid parameter prevents echo-back to the originating client, avoiding redundant UI updates:
// src/services/tripService.ts
import { broadcast } from '../../src/websocket';
// After persisting a new itinerary item
broadcast(tripId, 'itinerary:added', { itemId, data }, socketId);
This helper is defined in server/src/websocket.ts (lines 88-102) and used throughout the server codebase whenever trip data mutations occur.
User-Wide Broadcasts
For cross-trip notifications or user-specific updates, broadcastToUser(userId, payload, excludeSid?) walks all connected sockets, filtering for those belonging to the specific user (lines 105-118). This enables features like notifying a user across all their devices when their profile updates.
Client-Side Connection Management
The client implementation in client/src/api/websocket.ts uses a singleton pattern to maintain a single WebSocket connection across the application lifecycle.
Connection Lifecycle
The connect() function establishes the socket using the ephemeral token, while disconnect() performs graceful cleanup. The manager exposes addListener and removeListener methods that allow UI components to subscribe to specific event types without managing raw WebSocket objects:
// Client-side listener registration
import { addListener, joinTrip } from '@/api/websocket';
import { useTripStore } from '@/store/trip';
addListener((msg) => {
if (msg.type === 'itinerary:added' && msg.tripId) {
useTripStore().applyRemoteAdd(msg.tripId, msg.itemId, msg.data);
}
});
joinTrip(currentTripId);
Automatic Reconnection
The client handles network interruptions through an exponential backoff reconnection strategy. When the connection drops, the manager automatically attempts to reconnect and restores the previous session state.
State Recovery and Synchronization
TREK ensures data consistency across reconnections through a sophisticated sync layer that bridges the WebSocket API with the client-side store.
Pre-Reconnection Hooks
Before reconnecting, the client executes a preReconnectHook to flush any pending mutations from the mutationQueue. This ensures that local changes are persisted to the server before fetching fresh data:
import { setPreReconnectHook, setRefetchCallback } from '@/api/websocket';
setPreReconnectHook(() => mutationQueue.flush());
setRefetchCallback((tripId) => tripStore.refetch(tripId));
Trip Rejoining
Upon successful reconnection (onopen event), the client automatically rejoins all activeTrips and triggers refetchCallback to rehydrate the UI with the latest server state (lines 111-135 in client/src/api/websocket.ts). This mechanism prevents synchronization gaps that could occur during temporary network losses.
End-to-End Collaboration Flow
- User opens the application:
connect()initiates the handshake, fetching an ephemeral token and establishing the WebSocket. - Server authenticates: Validates the token, assigns a
socketId, and sends a welcome message. - User views a trip: Client calls
joinTrip(tripId), server verifies access viacanAccessTrip, and adds the socket to the room. - Collaborative edit occurs: When any user modifies trip data, the server calls
broadcast(tripId, eventType, payload). - Real-time updates: All clients in the room receive the event, and listeners in
client/src/sync/tripSyncManager.tsupdate local state, instantly reflecting changes across all connected devices.
Summary
- Ephemeral authentication: TREK uses short-lived WS tokens fetched via
POST /api/auth/ws-tokenand validated inserver/src/websocket.tsto secure connections without exposing long-lived JWTs. - Trip-scoped rooms: The server organizes sockets into rooms by
tripId, enabling efficient targeted broadcasting viabroadcast()andbroadcastToUser(). - Rate limiting: Each socket is limited to 30 messages per 10 seconds through
socketMsgCountsto prevent abuse. - Client singleton: The
client/src/api/websocket.tssingleton manages connection state, automatic rejoining of active trips, and listener registration. - State recovery: The sync layer in
client/src/sync/tripSyncManager.tsensures data consistency across reconnections by flushing mutation queues and refetching trip data.
Frequently Asked Questions
How does TREK authenticate WebSocket connections without sending JWTs in the URL?
TREK uses ephemeral tokens obtained via POST /api/auth/ws-token before connection establishment. The client passes this short-lived token as a query parameter (ws://…/ws?token=…), which the server validates using consumeEphemeralTokenWithMeta in server/src/websocket.ts. Once validated, the server associates the socket with the user ID and discards the ephemeral token, preventing credential exposure in server logs or browser history.
What happens when a user loses connection during a collaborative editing session?
The client automatically attempts to reconnect using exponential backoff. Upon reconnection, the preReconnectHook flushes any pending mutations from the mutationQueue, and the client rejoins all previously active trips. If a refetchCallback is configured, the client fetches fresh data to ensure consistency with any changes made by other users during the disconnection period.
How does TREK prevent a user from receiving their own updates twice?
The broadcast function in server/src/websocket.ts accepts an optional excludeSid parameter. When a user triggers a mutation (like adding an itinerary item), the server calls broadcast(tripId, 'itinerary:added', data, socketId), passing the originating socket's ID. The broadcast iteration skips this socket ID, preventing echo-back while ensuring all other participants receive the update.
What is the maximum number of messages a client can send through the WebSocket?
TREK enforces a rate limit of 30 messages per 10 seconds per socket through the socketMsgCounts tracking mechanism in server/src/websocket.ts. This limit prevents spam and ensures server resources remain available for legitimate collaborative operations.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →