Overview
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
$ npm install @vercube/ws
$ bun install @vercube/ws
Quick Start
Enable the WebSocket Plugin
import { createApp } from '@vercube/core';
import { WebsocketPlugin } from '@vercube/ws';
const app = createApp({
setup: async (app) => {
app.addPlugin(WebsocketPlugin);
}
});
Create a WebSocket Gateway
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
// 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:
- Client initiates WebSocket connection to a namespace
- Server calls
@OnConnectionAttempt()handler (if defined) - Handler validates connection (query params, headers)
- Connection accepted/rejected based on handler return value
Message Flow:
- Client sends message with
{ event: '...', data: {...} } - Server matches event to
@Message({ event: '...' })handler - Handler processes message and returns response
- 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 namespacews://server.com/notifications- Notificationsws://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