Skip to content

WebSocket API Reference

Eric Fitzgerald edited this page Nov 12, 2025 · 1 revision

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": "[email protected]",
      "email": "[email protected]",
      "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: '[email protected]',
    email: '[email protected]',
    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": "[email protected]",
    "email": "[email protected]",
    "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": "[email protected]",
      "email": "[email protected]",
      "displayName": "Alice Johnson",
      "role": "writer",
      "joined_at": "2025-01-15T12:00:00Z"
    },
    {
      "user_id": "[email protected]",
      "email": "[email protected]",
      "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": "[email protected]",
    "email": "[email protected]",
    "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": "[email protected]"
}

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": "[email protected]",
  "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": "[email protected]",
    "displayName": "Bob Smith"
  }
}

presenter_cursor

Presenter's cursor position.

Sent by: Presenter

Received by: All users

Structure:

{
  "message_type": "presenter_cursor",
  "user_id": "[email protected]",
  "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": "[email protected]",
  "selected_cell_ids": ["process-1", "datastore-2"]
}

State Synchronization

resync_request

Request full diagram state resync.

Sent by: Any user

Received by: Server

Structure:

{
  "message_type": "resync_request"
}

resync_response

Full diagram state for resync.

Sent by: Server

Received by: Requesting user

Structure:

{
  "message_type": "resync_response",
  "diagram_json": {
    "cells": [...],
    "assets": [...]
  },
  "version": 42
}

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: 60 seconds with ping/pong keepalive

Write Timeout: 10 seconds per message

Keepalive

Server sends ping frames every 30 seconds. Client should respond with pong.

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 15 minutes of inactivity (no connected clients).

Rate Limits

Message Size: 4KB maximum per message

Operation Size: 50KB maximum per operation

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

Related Documentation

Clone this wiki locally