Skip to content

WebSocket API Reference

Eric Fitzgerald edited this page Jan 26, 2026 · 3 revisions

WebSocket API Reference

This page documents TMI's WebSocket API for real-time collaborative diagram editing.

Overview

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

AsyncAPI Specification

Complete Specification:

WebSocket Endpoint

Connection URL

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

Authentication

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}`
    }
  }
);

Authorization

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.

Connection Flow

1. Establish Connection

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);
};

2. Receive Initial State

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
}

3. Send/Receive Operations

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;
  }
};

Message Types

Diagram Operations

diagram_operation

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"
  }
}

Participant Management

participants_update

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"
    }
  ]
}

participant_joined

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

Participant left session.

Sent by: Server

Received by: All users

Structure:

{
  "message_type": "participant_left",
  "user_id": "bob@example.com"
}

Presenter Mode

presenter_request

Request to become presenter (control edit lock).

Sent by: Writer user

Received by: Server

Structure:

{
  "message_type": "presenter_request"
}

current_presenter

Current presenter announced.

Sent by: Server

Received by: All users

Structure:

{
  "message_type": "current_presenter",
  "user_id": "alice@example.com",
  "displayName": "Alice Johnson"
}

presenter_denied

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_cursor

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_selection

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"]
}

State Synchronization

TMI uses a multi-message synchronization protocol for efficient state management:

sync_status_request

Check server's current update vector without receiving full state.

Sent by: Any user

Received by: Server

Structure:

{
  "message_type": "sync_status_request"
}

sync_status_response

Server responds with current update vector.

Sent by: Server

Received by: Requesting user

Structure:

{
  "message_type": "sync_status_response",
  "update_vector": 42
}

sync_request

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
}

diagram_state

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:

  1. Client can send sync_status_request to check if they're current
  2. Server responds with sync_status_response containing update_vector
  3. If client's vector differs, client sends sync_request with their vector
  4. Server sends diagram_state with 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

History

undo_request

Request to undo last operation.

Sent by: Writer user

Received by: Server

Structure:

{
  "message_type": "undo_request"
}

redo_request

Request to redo previously undone operation.

Sent by: Writer user

Received by: Server

Structure:

{
  "message_type": "redo_request"
}

history_operation

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
}

Error Handling

error

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

operation_rejected

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 */ }
}

authorization_denied

User lacks required permissions.

Sent by: Server

Received by: User (connection closes after)

Structure:

{
  "message_type": "authorization_denied",
  "required_role": "writer",
  "user_role": "reader"
}

Client Implementation

Complete Example (JavaScript)

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' }}
});

Connection Management

Timeouts

Read Timeout: 90 seconds (3x ping interval)

Write Timeout: 10 seconds per message

Max Message Size: 64 KB

Keepalive

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();
});

Reconnection

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
};

Session Cleanup

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.

Host Disconnection

When the session host disconnects:

  1. Session state changes to "terminating"
  2. All participants receive an error message: "Host has disconnected"
  3. All participant connections are closed
  4. Session is removed from the server

The session cannot continue without the host. This ensures the host maintains control over the collaboration session lifecycle.

Presenter Disconnection

When the current presenter disconnects (but is not the host):

  1. The host automatically becomes the new presenter
  2. If the host is not connected, the first participant with writer permissions becomes presenter
  3. If no writer-permission participants remain, the presenter role is cleared
  4. A participants_update is broadcast to all clients

Rate Limits

Message Size: 64KB maximum per message

Rate Limiting: Server may throttle excessive message rates

Best Practices

Performance

  1. Batch Operations: Combine multiple changes into single operation when possible
  2. Throttle Cursor Updates: Send cursor position max once per 100ms
  3. Debounce Selections: Delay selection updates to reduce traffic
  4. Connection Pooling: Reuse WebSocket connections

Reliability

  1. Implement Reconnection: Always handle connection drops
  2. Store Operation IDs: Detect and skip duplicate operations
  3. Request Resync: If state diverges, request full resync
  4. Handle Errors: Log and display user-friendly error messages

Security

  1. Validate Operations: Validate all incoming operations before applying
  2. Sanitize Data: Sanitize user-provided text and attributes
  3. Check Permissions: Respect read-only mode for reader users
  4. Secure Tokens: Store JWT tokens securely, not in localStorage

Troubleshooting

Connection Fails

Check:

  1. WebSocket URL format (ws:// vs wss://)
  2. JWT token validity
  3. Network connectivity
  4. Firewall/proxy settings

Operations Not Applied

Check:

  1. User has writer permissions
  2. Operation format is valid
  3. Path exists in diagram
  4. Check for error messages

State Divergence

Solution: Request resync

ws.send(JSON.stringify({ message_type: 'resync_request' }));

Node Position and Size Format

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

Collaboration Session REST API

Before establishing a WebSocket connection, use the REST API to manage collaboration sessions:

Discovering Sessions

GET /me/sessions - List all active sessions the user can access

Creating a Session

POST /threat_models/{id}/diagrams/{id}/collaborate - Create new session

  • Returns 201 on success
  • Returns 409 if session already exists (use PUT to join)

Joining a Session

PUT /threat_models/{id}/diagrams/{id}/collaborate - Join existing session

  • Returns 200 on success
  • Returns 404 if no session exists (use POST to create)

Leaving a Session

DELETE /threat_models/{id}/diagrams/{id}/collaborate - Leave session

Session Join Flow

Critical: Complete the REST API call before establishing the WebSocket connection:

  1. Call POST /collaborate to create or PUT to join
  2. Verify success via HTTP status code (201 or 200)
  3. Connect to WebSocket using the websocket_url from the response
  4. Handle initial state sync message

Echo Prevention

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);
});

TMI-UX Client Architecture

This section describes the internal architecture of the TMI-UX Angular client for handling collaborative diagram operations.

Service Architecture Overview

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

Outgoing Operations Flow

When a local user makes a diagram change:

  1. X6 Graph Event - The AntV X6 graph library emits a cell change event
  2. Event Handler - AppEventHandlersService captures the event
  3. Visual Filter - Visual-only changes (e.g., hover effects) are filtered out
  4. 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)
  5. Deduplication - Multiple operations on the same cell are merged:
    • remove operations take precedence over any other
    • add + update merges into single add with combined data
    • update + update merges data objects
  6. Message Creation - Creates diagram_operation_request with UUID and base_vector
  7. WebSocket Send - WebSocketAdapter.sendTMIMessage() transmits to server
  8. Queue Handling - If disconnected, operation is queued for retry when connection restores

Incoming Operations Flow

When a remote operation is received:

  1. WebSocket Message - InfraDfdWebsocketAdapter receives diagram_operation message
  2. Self-Filter - Operations from the current user are skipped (echo prevention)
  3. Event Emission - AppStateService emits applyBatchedOperationsEvent$
  4. Remote Handler - AppRemoteOperationHandler subscribes and processes:
    • Sets isApplyingRemoteChange = true to prevent re-broadcasting
    • Converts CellOperation (WebSocket format) to GraphOperation (internal format)
    • Executes through AppGraphOperationManager
  5. History Suppression - AppOperationStateManager.executeRemoteOperation() temporarily disables X6 history plugin
  6. Graph Update - Cell is added/updated/removed in the X6 graph
  7. Visual Effects - Remote creations show green highlight (vs blue for local)
  8. Flag Reset - isApplyingRemoteChange = false restored

State Correction and Resync

When the client detects state divergence:

  1. Sync Check - sync_status_response received with different update_vector
  2. Trigger - AppStateService emits triggerResyncEvent$
  3. Debounce - AppDiagramResyncService debounces multiple triggers (1 second default)
  4. REST Fetch - Latest diagram fetched via ThreatModelService.getDiagramById()
  5. Graph Clear - Current graph cells cleared
  6. Reload - Fresh cells loaded through AppDiagramLoadingService
  7. Vector Update - Local update_vector set to server's value
  8. Complete - AppStateService.resyncComplete() marks sync as successful

Presenter Mode

When presenter mode is active:

Cursor Broadcasting (UiPresenterCursorService):

  1. Mouse movement tracked on graph container
  2. Throttled to configured interval (50ms default)
  3. Position filtered if outside viewport
  4. Significant movement threshold applied (5px)
  5. Graph coordinates computed via graph.clientToGraph()
  6. presenter_cursor message sent via InfraWebsocketCollaborationAdapter

Selection Sync (UiPresenterSelectionService):

  1. Listens to X6 selection:changed events
  2. Only broadcasts if current user is presenter and mode is active
  3. Sends presenter_selection message with selected cell IDs
  4. Non-presenters receive and apply selection to their graph

Key Message Types Summary

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

Error Handling Strategy

The client implements graceful degradation:

  1. Retryable Errors (network, timeout):

    • Operations queued with configurable retry count (default 3)
    • Processed when connection restores
    • Old operations (>5 minutes) discarded
  2. Permission Errors (401, 403):

    • Not retried
    • Error propagated to user
    • UI updated to read-only if needed
  3. WebSocket Fallback:

    • If WebSocket operation fails, REST API fallback attempted
    • Full diagram state saved via PATCH endpoint
    • Ensures data persistence even during connection issues

Related Documentation

Clone this wiki locally