-
Notifications
You must be signed in to change notification settings - Fork 0
WebSocket API Reference
This page documents TMI's WebSocket API for real-time collaborative diagram editing.
The WebSocket API enables real-time collaboration on data flow diagrams with features including:
- Real-time editing: Multiple users editing simultaneously
- Presence detection: See who else is viewing/editing
- Live cursors: Track other users' cursor positions
- Conflict resolution: Automatic handling of concurrent edits
- Edit locking: Prevent conflicting operations
- Undo/Redo: Collaborative operation history
Complete Specification:
- File: tmi-asyncapi.yml
- Version: 1.0.0 (AsyncAPI 3.0.0)
- Interactive Documentation: AsyncAPI Studio
Development:
ws://localhost:8080/threat_models/{threat_model_id}/diagrams/{diagram_id}/ws
Production:
wss://api.tmi.dev/threat_models/{threat_model_id}/diagrams/{diagram_id}/ws
Parameters:
-
threat_model_id(UUID): Threat model identifier -
diagram_id(UUID): Diagram identifier
JWT Token: Include token in query parameter or header
Query Parameter (recommended):
const ws = new WebSocket(
`wss://api.tmi.dev/threat_models/${tmId}/diagrams/${diagramId}/ws?token=${jwtToken}`
);Header (alternative):
const ws = new WebSocket(
`wss://api.tmi.dev/threat_models/${tmId}/diagrams/${diagramId}/ws`,
{
headers: {
'Authorization': `Bearer ${jwtToken}`
}
}
);Permission Requirements:
- Reader: View diagram, see other users
- Writer: Edit diagram, modify content
- Owner: Full permissions, manage access
Users without required permissions receive authorization_denied message and connection closes.
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('Connected to diagram session');
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = (event) => {
console.log('Connection closed:', event.code, event.reason);
};Upon connection, server sends:
participants_update - Current participants:
{
"message_type": "participants_update",
"participants": [
{
"user_id": "alice@example.com",
"email": "alice@example.com",
"displayName": "Alice Johnson",
"role": "writer",
"joined_at": "2025-01-15T12:00:00Z"
}
]
}diagram_state_sync - Current diagram state (optional):
{
"message_type": "diagram_state_sync",
"diagram_json": {
"cells": [...],
"assets": [...]
},
"version": 42
}Sending operations:
const operation = {
message_type: 'diagram_operation',
initiating_user: {
user_id: 'alice@example.com',
email: 'alice@example.com',
displayName: 'Alice Johnson'
},
operation_id: crypto.randomUUID(),
sequence_number: 12345,
operation: {
op: 'add',
path: '/cells/-',
value: {
id: 'cell-123',
type: 'process',
position: { x: 100, y: 100 },
size: { width: 120, height: 60 },
attrs: {
label: { text: 'Web Server' }
}
}
}
};
ws.send(JSON.stringify(operation));Receiving operations from others:
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.message_type) {
case 'diagram_operation':
applyOperation(message.operation);
break;
case 'participant_joined':
addParticipant(message.participant);
break;
case 'participant_left':
removeParticipant(message.user_id);
break;
case 'presenter_cursor':
updateCursor(message.user_id, message.position);
break;
case 'error':
handleError(message);
break;
}
};Apply changes to diagram (add/update/remove cells).
Sent by: Writer users
Received by: All connected users
Structure:
{
"message_type": "diagram_operation",
"initiating_user": {
"user_id": "alice@example.com",
"email": "alice@example.com",
"displayName": "Alice Johnson"
},
"operation_id": "uuid",
"sequence_number": 12345,
"operation": {
"op": "add",
"path": "/cells/-",
"value": { /* cell data */ }
}
}Operation types:
-
add: Add new cell -
replace: Update existing cell -
remove: Delete cell
Paths:
-
/cells/-: Add cell to end of array -
/cells/{index}: Update/remove specific cell -
/assets/{index}: Update asset
Example - Add cell:
{
"message_type": "diagram_operation",
"initiating_user": {...},
"operation_id": "550e8400-e29b-41d4-a716-446655440000",
"sequence_number": 100,
"operation": {
"op": "add",
"path": "/cells/-",
"value": {
"id": "process-1",
"type": "process",
"position": { "x": 100, "y": 100 },
"size": { "width": 120, "height": 60 },
"attrs": {
"label": { "text": "API Gateway" }
}
}
}
}Example - Update cell:
{
"operation": {
"op": "replace",
"path": "/cells/0/position",
"value": { "x": 150, "y": 120 }
}
}Example - Remove cell:
{
"operation": {
"op": "remove",
"path": "/cells/0"
}
}Complete list of current participants.
Sent by: Server
Received by: All users (on join, leave, or change)
Structure:
{
"message_type": "participants_update",
"participants": [
{
"user_id": "alice@example.com",
"email": "alice@example.com",
"displayName": "Alice Johnson",
"role": "writer",
"joined_at": "2025-01-15T12:00:00Z"
},
{
"user_id": "bob@example.com",
"email": "bob@example.com",
"displayName": "Bob Smith",
"role": "reader",
"joined_at": "2025-01-15T12:05:00Z"
}
]
}New participant joined session.
Sent by: Server
Received by: All users
Structure:
{
"message_type": "participant_joined",
"participant": {
"user_id": "carol@example.com",
"email": "carol@example.com",
"displayName": "Carol Williams",
"role": "writer",
"joined_at": "2025-01-15T12:10:00Z"
}
}Participant left session.
Sent by: Server
Received by: All users
Structure:
{
"message_type": "participant_left",
"user_id": "bob@example.com"
}Request to become presenter (control edit lock).
Sent by: Writer user
Received by: Server
Structure:
{
"message_type": "presenter_request"
}Current presenter announced.
Sent by: Server
Received by: All users
Structure:
{
"message_type": "current_presenter",
"user_id": "alice@example.com",
"displayName": "Alice Johnson"
}Presenter request denied (another user is presenter).
Sent by: Server
Received by: Requesting user
Structure:
{
"message_type": "presenter_denied",
"current_presenter": {
"user_id": "bob@example.com",
"displayName": "Bob Smith"
}
}Presenter's cursor position.
Sent by: Presenter
Received by: All users
Structure:
{
"message_type": "presenter_cursor",
"user_id": "alice@example.com",
"position": {
"x": 250,
"y": 180
}
}Presenter's selected cells.
Sent by: Presenter
Received by: All users
Structure:
{
"message_type": "presenter_selection",
"user_id": "alice@example.com",
"selected_cell_ids": ["process-1", "datastore-2"]
}TMI uses a multi-message synchronization protocol for efficient state management:
Check server's current update vector without receiving full state.
Sent by: Any user
Received by: Server
Structure:
{
"message_type": "sync_status_request"
}Server responds with current update vector.
Sent by: Server
Received by: Requesting user
Structure:
{
"message_type": "sync_status_response",
"update_vector": 42
}Request full diagram state if client is stale. If update_vector matches server's, server sends sync_status_response instead.
Sent by: Any user
Received by: Server
Structure:
{
"message_type": "sync_request",
"update_vector": 40
}Full diagram state sent on initial connection or in response to sync_request.
Sent by: Server
Received by: Requesting user (or all users on initial connection)
Structure:
{
"message_type": "diagram_state",
"diagram_id": "uuid",
"update_vector": 42,
"cells": [...]
}Sync Protocol Flow:
- Client can send
sync_status_requestto check if they're current - Server responds with
sync_status_responsecontainingupdate_vector - If client's vector differs, client sends
sync_requestwith their vector - Server sends
diagram_statewith full state if client is stale
Conflict Detection: TMI uses cell-level state validation. Operations are validated against current cell existence:
- Add: If cell exists, treated as idempotent update
- Update: Rejected if cell doesn't exist
- Remove: Idempotent if cell doesn't exist
Request to undo last operation.
Sent by: Writer user
Received by: Server
Structure:
{
"message_type": "undo_request"
}Request to redo previously undone operation.
Sent by: Writer user
Received by: Server
Structure:
{
"message_type": "redo_request"
}Operation from history (undo/redo result).
Sent by: Server
Received by: All users
Structure:
{
"message_type": "history_operation",
"operation": {
"op": "remove",
"path": "/cells/0"
},
"is_undo": true
}General error message.
Sent by: Server
Received by: Affected user
Structure:
{
"message_type": "error",
"error_code": "invalid_operation",
"error_message": "Operation path not found: /cells/99"
}Common error codes:
-
invalid_operation: Malformed operation -
permission_denied: Insufficient permissions -
rate_limit_exceeded: Too many messages -
session_expired: Session timed out
Specific operation rejected.
Sent by: Server
Received by: Submitting user
Structure:
{
"message_type": "operation_rejected",
"operation_id": "550e8400-e29b-41d4-a716-446655440000",
"reason": "Cell ID already exists",
"rejected_operation": { /* original operation */ }
}User lacks required permissions.
Sent by: Server
Received by: User (connection closes after)
Structure:
{
"message_type": "authorization_denied",
"required_role": "writer",
"user_role": "reader"
}class DiagramCollaborationClient {
constructor(threatModelId, diagramId, token) {
this.threatModelId = threatModelId;
this.diagramId = diagramId;
this.token = token;
this.ws = null;
this.sequenceNumber = 0;
this.participants = new Map();
}
connect() {
const baseUrl = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${baseUrl}//api.tmi.dev/threat_models/${this.threatModelId}/diagrams/${this.diagramId}/ws?token=${this.token}`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('Connected to collaboration session');
this.onConnected();
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.onError(error);
};
this.ws.onclose = (event) => {
console.log('Connection closed:', event.code, event.reason);
this.onDisconnected(event);
// Attempt reconnection after delay
if (event.code !== 1000) {
setTimeout(() => this.connect(), 5000);
}
};
}
handleMessage(message) {
switch (message.message_type) {
case 'participants_update':
this.updateParticipants(message.participants);
break;
case 'participant_joined':
this.addParticipant(message.participant);
break;
case 'participant_left':
this.removeParticipant(message.user_id);
break;
case 'diagram_operation':
this.applyOperation(message.operation);
break;
case 'presenter_cursor':
this.updateCursor(message.user_id, message.position);
break;
case 'current_presenter':
this.setPresenter(message.user_id);
break;
case 'error':
this.handleError(message);
break;
}
}
sendOperation(operation) {
const message = {
message_type: 'diagram_operation',
initiating_user: this.getCurrentUser(),
operation_id: crypto.randomUUID(),
sequence_number: ++this.sequenceNumber,
operation: operation
};
this.ws.send(JSON.stringify(message));
}
addCell(cell) {
this.sendOperation({
op: 'add',
path: '/cells/-',
value: cell
});
}
updateCell(index, updates) {
this.sendOperation({
op: 'replace',
path: `/cells/${index}`,
value: updates
});
}
removeCell(index) {
this.sendOperation({
op: 'remove',
path: `/cells/${index}`
});
}
requestPresenter() {
this.ws.send(JSON.stringify({
message_type: 'presenter_request'
}));
}
sendCursorPosition(x, y) {
this.ws.send(JSON.stringify({
message_type: 'presenter_cursor',
user_id: this.getCurrentUser().user_id,
position: { x, y }
}));
}
undo() {
this.ws.send(JSON.stringify({
message_type: 'undo_request'
}));
}
redo() {
this.ws.send(JSON.stringify({
message_type: 'redo_request'
}));
}
disconnect() {
if (this.ws) {
this.ws.close(1000, 'Client disconnect');
}
}
// Override these methods in subclass
onConnected() {}
onDisconnected(event) {}
onError(error) {}
applyOperation(operation) {}
updateParticipants(participants) {}
addParticipant(participant) {}
removeParticipant(userId) {}
updateCursor(userId, position) {}
setPresenter(userId) {}
handleError(message) {}
getCurrentUser() { return {}; }
}
// Usage
const client = new DiagramCollaborationClient(
'threat-model-uuid',
'diagram-uuid',
'jwt-token'
);
client.onConnected = () => {
console.log('Ready to collaborate!');
};
client.applyOperation = (operation) => {
// Apply operation to local diagram
console.log('Received operation:', operation);
};
client.connect();
// Add a cell
client.addCell({
id: 'process-1',
type: 'process',
position: { x: 100, y: 100 },
attrs: { label: { text: 'API Gateway' }}
});Read Timeout: 90 seconds (3x ping interval)
Write Timeout: 10 seconds per message
Max Message Size: 64 KB
Server sends WebSocket ping frames every 30 seconds. Client must respond with pong frames. If no message or pong is received within 90 seconds, the connection is automatically closed.
ws.addEventListener('ping', () => {
ws.pong();
});Implement exponential backoff for reconnection:
let reconnectDelay = 1000;
const maxDelay = 30000;
function reconnect() {
setTimeout(() => {
connect();
reconnectDelay = Math.min(reconnectDelay * 2, maxDelay);
}, reconnectDelay);
}
ws.onclose = (event) => {
if (event.code !== 1000) { // Not normal closure
reconnect();
}
};
ws.onopen = () => {
reconnectDelay = 1000; // Reset on successful connection
};Sessions automatically cleanup after 5 minutes of inactivity by default (configurable via WEBSOCKET_INACTIVITY_TIMEOUT_SECONDS environment variable, minimum 15 seconds). Empty sessions (no connected clients) are cleaned up immediately.
When the session host disconnects:
- Session state changes to "terminating"
- All participants receive an error message: "Host has disconnected"
- All participant connections are closed
- Session is removed from the server
The session cannot continue without the host. This ensures the host maintains control over the collaboration session lifecycle.
When the current presenter disconnects (but is not the host):
- The host automatically becomes the new presenter
- If the host is not connected, the first participant with writer permissions becomes presenter
- If no writer-permission participants remain, the presenter role is cleared
- A
participants_updateis broadcast to all clients
Message Size: 64KB maximum per message
Rate Limiting: Server may throttle excessive message rates
- Batch Operations: Combine multiple changes into single operation when possible
- Throttle Cursor Updates: Send cursor position max once per 100ms
- Debounce Selections: Delay selection updates to reduce traffic
- Connection Pooling: Reuse WebSocket connections
- Implement Reconnection: Always handle connection drops
- Store Operation IDs: Detect and skip duplicate operations
- Request Resync: If state diverges, request full resync
- Handle Errors: Log and display user-friendly error messages
- Validate Operations: Validate all incoming operations before applying
- Sanitize Data: Sanitize user-provided text and attributes
- Check Permissions: Respect read-only mode for reader users
- Secure Tokens: Store JWT tokens securely, not in localStorage
Check:
- WebSocket URL format (ws:// vs wss://)
- JWT token validity
- Network connectivity
- Firewall/proxy settings
Check:
- User has writer permissions
- Operation format is valid
- Path exists in diagram
- Check for error messages
Solution: Request resync
ws.send(JSON.stringify({ message_type: 'resync_request' }));The TMI API supports two formats for node position and size properties:
Format 1 (Nested - Legacy):
{
"id": "uuid",
"shape": "process",
"position": { "x": 100, "y": 200 },
"size": { "width": 80, "height": 60 }
}Format 2 (Flat - Recommended):
{
"id": "uuid",
"shape": "process",
"x": 100,
"y": 200,
"width": 80,
"height": 60
}Key Points:
- Input: The API accepts both formats when creating or updating nodes
- Output: The API always returns flat format (Format 2) in responses
- Minimum dimensions: width >= 40, height >= 30
- Migration: Existing clients using nested format will continue to work
Before establishing a WebSocket connection, use the REST API to manage collaboration sessions:
GET /me/sessions - List all active sessions the user can access
POST /threat_models/{id}/diagrams/{id}/collaborate - Create new session
- Returns 201 on success
- Returns 409 if session already exists (use PUT to join)
PUT /threat_models/{id}/diagrams/{id}/collaborate - Join existing session
- Returns 200 on success
- Returns 404 if no session exists (use POST to create)
DELETE /threat_models/{id}/diagrams/{id}/collaborate - Leave session
Critical: Complete the REST API call before establishing the WebSocket connection:
- Call POST
/collaborateto create or PUT to join - Verify success via HTTP status code (201 or 200)
- Connect to WebSocket using the
websocket_urlfrom the response - Handle initial state sync message
When applying remote operations, prevent echo loops by tracking a flag:
handleDiagramOperation(message) {
if (message.user_id === this.currentUser.email) {
return; // Skip own operations echoed back
}
this.isApplyingRemoteChange = true;
try {
this.applyOperationToEditor(message.operation);
} finally {
this.isApplyingRemoteChange = false;
}
}When sending local changes, check the flag:
diagramEditor.on('cellChanged', (change) => {
if (this.isApplyingRemoteChange) {
return; // Don't send WebSocket message for remote changes
}
this.sendOperation(change);
});This section describes the internal architecture of the TMI-UX Angular client for handling collaborative diagram operations.
The TMI-UX client implements a layered architecture for WebSocket communication:
| Layer | Service | Responsibility |
|---|---|---|
| Core | WebSocketAdapter |
Low-level WebSocket connection management |
| Infrastructure | InfraWebsocketCollaborationAdapter |
Diagram operation sending, permission checks, deduplication |
| Application | AppStateService |
State management, event coordination, sync handling |
| Application | AppRemoteOperationHandler |
Processing incoming remote operations |
| Application | AppDiagramResyncService |
Debounced resynchronization from REST API |
| Presentation | UiPresenterCursorService |
Presenter cursor broadcasting |
| Presentation | UiPresenterSelectionService |
Presenter selection sync |
When a local user makes a diagram change:
- X6 Graph Event - The AntV X6 graph library emits a cell change event
-
Event Handler -
AppEventHandlersServicecaptures the event - Visual Filter - Visual-only changes (e.g., hover effects) are filtered out
-
Permission Check -
InfraWebsocketCollaborationAdapter.sendDiagramOperation()verifies:- If collaborating: checks
DfdCollaborationService.hasPermission('edit') - Fallback to threat model permission while collaboration permissions load
- If solo: checks threat model permission (writer required)
- If collaborating: checks
-
Deduplication - Multiple operations on the same cell are merged:
-
removeoperations take precedence over any other -
add + updatemerges into singleaddwith combined data -
update + updatemerges data objects
-
-
Message Creation - Creates
diagram_operation_requestwith UUID and base_vector -
WebSocket Send -
WebSocketAdapter.sendTMIMessage()transmits to server - Queue Handling - If disconnected, operation is queued for retry when connection restores
When a remote operation is received:
-
WebSocket Message -
InfraDfdWebsocketAdapterreceivesdiagram_operationmessage - Self-Filter - Operations from the current user are skipped (echo prevention)
-
Event Emission -
AppStateServiceemitsapplyBatchedOperationsEvent$ -
Remote Handler -
AppRemoteOperationHandlersubscribes and processes:- Sets
isApplyingRemoteChange = trueto prevent re-broadcasting - Converts
CellOperation(WebSocket format) toGraphOperation(internal format) - Executes through
AppGraphOperationManager
- Sets
-
History Suppression -
AppOperationStateManager.executeRemoteOperation()temporarily disables X6 history plugin - Graph Update - Cell is added/updated/removed in the X6 graph
- Visual Effects - Remote creations show green highlight (vs blue for local)
-
Flag Reset -
isApplyingRemoteChange = falserestored
When the client detects state divergence:
-
Sync Check -
sync_status_responsereceived with differentupdate_vector -
Trigger -
AppStateServiceemitstriggerResyncEvent$ -
Debounce -
AppDiagramResyncServicedebounces multiple triggers (1 second default) -
REST Fetch - Latest diagram fetched via
ThreatModelService.getDiagramById() - Graph Clear - Current graph cells cleared
-
Reload - Fresh cells loaded through
AppDiagramLoadingService -
Vector Update - Local
update_vectorset to server's value -
Complete -
AppStateService.resyncComplete()marks sync as successful
When presenter mode is active:
Cursor Broadcasting (UiPresenterCursorService):
- Mouse movement tracked on graph container
- Throttled to configured interval (50ms default)
- Position filtered if outside viewport
- Significant movement threshold applied (5px)
- Graph coordinates computed via
graph.clientToGraph() -
presenter_cursormessage sent viaInfraWebsocketCollaborationAdapter
Selection Sync (UiPresenterSelectionService):
- Listens to X6
selection:changedevents - Only broadcasts if current user is presenter and mode is active
- Sends
presenter_selectionmessage with selected cell IDs - Non-presenters receive and apply selection to their graph
| Category | Message Type | Direction | Description |
|---|---|---|---|
| Diagram | diagram_operation_request |
Client -> Server | Cell add/update/remove operations |
| Diagram | diagram_operation |
Server -> Client | Broadcast of applied operation |
| History | undo_request |
Client -> Server | Request server-side undo |
| History | redo_request |
Client -> Server | Request server-side redo |
| History | history_operation |
Server -> Client | Undo/redo result |
| Sync | sync_request |
Client -> Server | Request full diagram refresh |
| Sync | sync_status_request |
Client -> Server | Check current update vector |
| Sync | sync_status_response |
Server -> Client | Current server update vector |
| Sync | diagram_state |
Server -> Client | Full diagram state |
| Presenter | presenter_cursor |
Presenter -> All | Presenter cursor position |
| Presenter | presenter_selection |
Presenter -> All | Presenter selection changes |
| Presenter | presenter_request |
Client -> Server | Request to become presenter |
| Presenter | presenter_denied |
Server -> Client | Presenter request denied |
| Presenter | current_presenter |
Server -> All | Current presenter announcement |
| Session | participants_update |
Server -> All | Full participant list update |
| Error | authorization_denied |
Server -> Client | Operation rejected due to permissions |
| Error | error |
Server -> Client | General error message |
The client implements graceful degradation:
-
Retryable Errors (network, timeout):
- Operations queued with configurable retry count (default 3)
- Processed when connection restores
- Old operations (>5 minutes) discarded
-
Permission Errors (401, 403):
- Not retried
- Error propagated to user
- UI updated to read-only if needed
-
WebSocket Fallback:
- If WebSocket operation fails, REST API fallback attempted
- Full diagram state saved via PATCH endpoint
- Ensures data persistence even during connection issues
- API-Overview - API authentication and design
- REST-API-Reference - REST API endpoints
- API-Workflows - Common usage patterns
- Working-with-Data-Flow-Diagrams - Diagram editing guide
- Collaborative-Threat-Modeling - Collaboration features
- WebSocket-Test-Harness - Go-based tool for testing WebSocket collaboration
- AsyncAPI Specification
- Collaboration Protocol Flow - Detailed protocol documentation with message sequences
- Client WebSocket Integration Guide - Comprehensive client implementation patterns
- Using TMI for Threat Modeling
- Accessing TMI
- Creating Your First Threat Model
- Understanding the User Interface
- Working with Data Flow Diagrams
- Managing Threats
- Collaborative Threat Modeling
- Using Notes and Documentation
- Metadata and Extensions
- Planning Your Deployment
- Deploying TMI Server
- OCI Container Deployment
- Deploying TMI Web Application
- Setting Up Authentication
- Database Setup
- Component Integration
- Post-Deployment
- Monitoring and Health
- Database Operations
- Security Operations
- Performance and Scaling
- Maintenance Tasks