-
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": "[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
}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;
}
};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"
}
}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"
}
]
}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 session.
Sent by: Server
Received by: All users
Structure:
{
"message_type": "participant_left",
"user_id": "[email protected]"
}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": "[email protected]",
"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": "[email protected]",
"displayName": "Bob Smith"
}
}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'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"]
}Request full diagram state resync.
Sent by: Any user
Received by: Server
Structure:
{
"message_type": "resync_request"
}Full diagram state for resync.
Sent by: Server
Received by: Requesting user
Structure:
{
"message_type": "resync_response",
"diagram_json": {
"cells": [...],
"assets": [...]
},
"version": 42
}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: 60 seconds with ping/pong keepalive
Write Timeout: 10 seconds per message
Server sends ping frames every 30 seconds. Client should respond with pong.
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 15 minutes of inactivity (no connected clients).
Message Size: 4KB maximum per message
Operation Size: 50KB maximum per operation
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' }));- 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
- AsyncAPI Specification
- 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
- 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