Overview

Real-time bidirectional communication with decorator-based API

The WebSocket module enables real-time bidirectional communication between clients and your Vercube server using WebSocket connections. Built on top of crossws, it provides a decorator-based API that makes WebSocket development intuitive and type-safe.

Installation

$ pnpm add @vercube/ws

Quick Start

Enable the WebSocket Plugin

src/index.ts
import { createApp } from '@vercube/core';
import { WebsocketPlugin } from '@vercube/ws';

const app = createApp({
  setup: async (app) => {
    app.addPlugin(WebsocketPlugin);
  }
});

Create a WebSocket Gateway

src/gateways/ChatGateway.ts
import { Controller } from '@vercube/core';
import { 
  Namespace, 
  Message, 
  Emit, 
  OnConnectionAttempt 
} from '@vercube/ws';
import type { Peer } from 'crossws';

@Namespace('/chat')
@Controller()
export class ChatGateway {
  
  // Optional: Control connection access
  @OnConnectionAttempt()
  async handleConnection(
    params: Record<string, unknown>, 
    request: Request
  ): Promise<boolean> {
    // Validate authentication token from query params
    const token = params.token as string;
    
    if (!token || !this.isValidToken(token)) {
      return false; // Reject connection
    }
    
    return true; // Accept connection
  }
  
  // Listen for 'message' events from clients
  @Message({ event: 'message' })
  @Emit('message-received')
  async onMessage(
    data: { text: string; user: string }, 
    peer: Peer
  ) {
    console.log(`Message from ${peer.id}:`, data);
    
    // Return value is automatically emitted to the sender
    return {
      status: 'received',
      timestamp: new Date().toISOString()
    };
  }
  
  private isValidToken(token: string): boolean {
    // Your token validation logic
    return true;
  }
}

Connect from the Client

client.ts
// Connect to the chat namespace
const ws = new WebSocket('ws://localhost:3000/chat?token=your-auth-token');

// Handle connection
ws.onopen = () => {
  console.log('Connected to chat');
  
  // Send a message
  ws.send(JSON.stringify({
    event: 'message',
    data: {
      text: 'Hello, server!',
      user: 'Alice'
    }
  }));
};

// Receive messages
ws.onmessage = (event) => {
  const message = JSON.parse(event.data);
  console.log('Received:', message);
};

ws.onerror = (error) => {
  console.error('WebSocket error:', error);
};

ws.onclose = () => {
  console.log('Disconnected from chat');
};

Core Concepts

Namespaces

Namespaces are logical channels that group WebSocket connections. They allow you to organize different types of real-time functionality within your application.

@Namespace('/chat')    // Chat namespace at ws://yourserver.com/chat
@Namespace('/notifications')  // Notifications namespace

Each namespace:

  • Has its own connection URL path
  • Maintains separate client connections
  • Can have different connection handlers
  • Isolates message events

Events

Events are named message types that clients and servers use to communicate. Think of them as typed message channels within a namespace.

// Server listens for 'message' event from clients
@Message({ event: 'message' })
handleMessage(data: any, peer: Peer) { ... }

// Server emits 'update' event to client(s)
@Emit('update')
sendUpdate() { ... }

Peers

A peer represents a connected WebSocket client. Each peer has:

  • Unique ID: Automatically assigned identifier
  • Namespace: The namespace they're connected to
  • IP Address: Client's IP address
  • Send method: To send messages directly to that peer
interface Peer {
  id: string;
  namespace?: string;
  send(message: unknown): void;
}

Message Flow

Understanding how messages flow through the WebSocket system is crucial:

Connection Flow:

  1. Client initiates WebSocket connection to a namespace
  2. Server calls @OnConnectionAttempt() handler (if defined)
  3. Handler validates connection (query params, headers)
  4. Connection accepted/rejected based on handler return value

Message Flow:

  1. Client sends message with { event: '...', data: {...} }
  2. Server matches event to @Message({ event: '...' }) handler
  3. Handler processes message and returns response
  4. Response decorators (@Emit, @Broadcast) send data back

Message Handlers

Listening to Events

Use @Message() to listen for specific events from clients:

@Namespace('/chat')
@Controller()
export class ChatGateway {
  
  @Message({ event: 'send-message' })
  handleMessage(data: any, peer: Peer) {
    console.log(`Received from ${peer.id}:`, data);
    // Process message
  }
  
  @Message({ event: 'typing' })
  handleTyping(data: { isTyping: boolean }, peer: Peer) {
    // Handle typing indicator
  }
}

Validation

Add schema validation to ensure message data integrity:

import { z } from 'zod';

const MessageSchema = z.object({
  text: z.string().min(1).max(500),
  user: z.string(),
  room: z.string().optional()
});

@Message({ 
  event: 'send-message',
  validationSchema: MessageSchema 
})
handleMessage(data: z.infer<typeof MessageSchema>, peer: Peer) {
  // data is typed and validated
  console.log(data.text);
}

Invalid messages are automatically rejected with validation errors.

Sending Messages

Emit to Sender

Send message back to the client that triggered the handler:

@Message({ event: 'ping' })
@Emit('pong')
handlePing() {
  return { timestamp: Date.now() };
}

// Client receives: { event: 'pong', data: { timestamp: ... } }

Broadcast to All

Send message to all clients in the namespace (including sender):

@Message({ event: 'user-joined' })
@Broadcast('user-status')
handleUserJoined(data: { username: string }) {
  return { 
    type: 'joined',
    username: data.username,
    timestamp: Date.now()
  };
}

// All clients receive the message

Broadcast to Others

Send message to all clients except the sender:

@Message({ event: 'typing' })
@BroadcastOthers('user-typing')
handleTyping(data: { username: string }) {
  return {
    username: data.username,
    isTyping: true
  };
}

// All clients except the sender receive the message

Connection Management

Accepting Connections

Control who can connect to your namespace:

@Namespace('/private-chat')
@Controller()
export class PrivateChatGateway {
  
  @Inject(AuthService)
  private authService!: AuthService;
  
  @OnConnectionAttempt()
  async validateConnection(
    params: Record<string, unknown>,
    request: Request
  ): Promise<boolean> {
    const token = params.token as string;
    
    try {
      // Validate JWT token
      const user = await this.authService.verifyToken(token);
      
      // Check permissions
      if (!user.hasPermission('access-private-chat')) {
        return false;
      }
      
      return true;
    } catch (error) {
      return false;
    }
  }
}

Rejecting Connections

Return false or throw an error to reject:

@OnConnectionAttempt()
async validateConnection(params: Record<string, unknown>) {
  if (!params.token) {
    throw new Error('Token required');
  }
  
  const isValid = await this.validateToken(params.token as string);
  
  if (!isValid) {
    return false; // Reject with 403
  }
  
  return true;
}

Working with Multiple Namespaces

You can create multiple namespaces for different purposes:

// Chat namespace
@Namespace('/chat')
@Controller()
export class ChatGateway {
  @Message({ event: 'message' })
  handleMessage(data: any) { ... }
}

// Notifications namespace
@Namespace('/notifications')
@Controller()
export class NotificationsGateway {
  @Message({ event: 'subscribe' })
  handleSubscribe(data: any) { ... }
}

// Admin namespace
@Namespace('/admin')
@Controller()
export class AdminGateway {
  @OnConnectionAttempt()
  async validateAdmin(params: Record<string, unknown>) {
    // Only allow admins
    return this.authService.isAdmin(params.token);
  }
  
  @Message({ event: 'broadcast' })
  @Broadcast('admin-message')
  handleBroadcast(data: any) { ... }
}

Clients connect to different URLs:

  • ws://server.com/chat - Chat namespace
  • ws://server.com/notifications - Notifications
  • ws://server.com/admin - Admin only

Advanced Patterns

Room-Based Messaging

Implement room functionality using namespaces and filtering:

@Namespace('/chat')
@Controller()
export class ChatGateway {
  private rooms = new Map<string, Set<string>>(); // room -> peer IDs
  
  @Inject($WebsocketService)
  private ws!: WebsocketService;
  
  @Message({ event: 'join-room' })
  joinRoom(data: { room: string }, peer: Peer) {
    if (!this.rooms.has(data.room)) {
      this.rooms.set(data.room, new Set());
    }
    
    this.rooms.get(data.room)!.add(peer.id);
    
    // Notify room members
    this.broadcastToRoom(data.room, 'user-joined', {
      userId: peer.id,
      room: data.room
    }, peer);
  }
  
  @Message({ event: 'room-message' })
  sendToRoom(data: { room: string; text: string }, peer: Peer) {
    this.broadcastToRoom(data.room, 'message', {
      from: peer.id,
      text: data.text
    }, peer);
  }
  
  private broadcastToRoom(
    room: string, 
    event: string, 
    data: any,
    sender: Peer
  ) {
    const roomPeers = this.rooms.get(room);
    if (!roomPeers) return;
    
    // Send to all peers in room except sender
    for (const peerId of roomPeers) {
      if (peerId !== sender.id) {
        // You'd need to track peers separately
        // This is a simplified example
      }
    }
  }
}

Private Messaging

Send messages between specific peers:

@Namespace('/chat')
@Controller()
export class ChatGateway {
  private peers = new Map<string, Peer>(); // userId -> Peer
  
  @Message({ event: 'register' })
  registerUser(data: { userId: string }, peer: Peer) {
    this.peers.set(data.userId, peer);
  }
  
  @Message({ event: 'private-message' })
  sendPrivate(data: { to: string; text: string }, sender: Peer) {
    const recipient = this.peers.get(data.to);
    
    if (recipient) {
      recipient.send({
        event: 'private-message',
        data: {
          from: sender.id,
          text: data.text
        }
      });
      
      return { status: 'delivered' };
    }
    
    return { status: 'user-offline' };
  }
}

Heartbeat / Ping-Pong

Keep connections alive and detect disconnects:

@Namespace('/realtime')
@Controller()
export class RealtimeGateway {
  
  @Message({ event: 'ping' })
  @Emit('pong')
  handlePing() {
    return { timestamp: Date.now() };
  }
}

// Client-side
const ws = new WebSocket('ws://server.com/realtime');
let pingInterval: NodeJS.Timeout;

ws.onopen = () => {
  // Send ping every 30 seconds
  pingInterval = setInterval(() => {
    ws.send(JSON.stringify({ event: 'ping', data: {} }));
  }, 30000);
};

ws.onclose = () => {
  clearInterval(pingInterval);
};

Presence Tracking

Track online users in a namespace:

@Namespace('/presence')
@Controller()
export class PresenceGateway {
  private onlineUsers = new Set<string>();
  
  @OnConnectionAttempt()
  async handleConnection(params: Record<string, unknown>) {
    const userId = params.userId as string;
    this.onlineUsers.add(userId);
    
    // Broadcast updated presence
    this.broadcastPresence();
    
    return true;
  }
  
  @Message({ event: 'disconnect' })
  handleDisconnect(data: { userId: string }) {
    this.onlineUsers.delete(data.userId);
    this.broadcastPresence();
  }
  
  private broadcastPresence() {
    // Broadcast to all connected clients
    // Implementation depends on how you track peers
  }
}

Error Handling

Handler Errors

Errors in message handlers are caught and logged:

@Message({ event: 'risky-operation' })
async handleRiskyOp(data: any, peer: Peer) {
  try {
    const result = await this.performOperation(data);
    return { success: true, result };
  } catch (error) {
    console.error('Operation failed:', error);
    return { 
      success: false, 
      error: error.message 
    };
  }
}

Validation Errors

Schema validation errors are automatically handled:

const StrictSchema = z.object({
  name: z.string().min(3),
  age: z.number().positive()
});

@Message({ 
  event: 'submit',
  validationSchema: StrictSchema 
})
handleSubmit(data: z.infer<typeof StrictSchema>) {
  // If validation fails, this handler never runs
  // Client receives validation error details
}

Troubleshooting

"Namespace not registered"

# Error: Namespace "/chat" is not registered. Connection rejected.

Solution: Make sure your gateway class has the @Namespace() decorator

"No message handler for event"

# Warning: No message handler for event "foo" in namespace "/chat"

Solution: Add a @Message({ event: 'foo' }) handler or check event name spelling

"WebsocketService is not registered"

# Warning: MessageDecorator::WebsocketService is not registered

Solution: Add WebsocketPlugin to your app setup

Validation errors

# Message validation error: [{ path: 'text', message: 'Required' }]

Solution: Check your message data matches the validation schema

Previous

API

Complete API reference for all WebSocket decorators

Next