API
This page provides a complete reference for all decorators available in the @vercube/ws module. Each decorator is explained with its purpose, parameters, behavior, and practical examples.
@Namespace
The @Namespace decorator defines a WebSocket namespace path and must be applied to a controller class.
Signature
function Namespace(path: string): ClassDecorator
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
path | string | Yes | The namespace path (URL path) for WebSocket connections |
Behavior
- Registers the namespace path in the WebSocket service
- Clients connect to
ws://yourserver.com{path} - All message handlers in the controller are scoped to this namespace
- Must be used on the same class as
@Controller()
Examples
@Namespace('/chat')
@Controller()
export class ChatGateway {
// All handlers are under /chat namespace
}
// Clients connect to: ws://yourserver.com/chat
@Namespace('/public-chat')
@Controller()
export class PublicChatGateway {
@Message({ event: 'message' })
handleMessage(data: any) { ... }
}
@Namespace('/private-chat')
@Controller()
export class PrivateChatGateway {
@Message({ event: 'message' })
handleMessage(data: any) { ... }
}
// Clients connect to:
// - ws://yourserver.com/public-chat
// - ws://yourserver.com/private-chat
@Namespace('/api/v1/realtime')
@Controller()
export class RealtimeGateway {
// ...
}
// Clients connect to: ws://yourserver.com/api/v1/realtime
Rules
- Path is case-insensitive (internally normalized to lowercase)
- Leading slash is recommended but not required
- Each namespace is isolated - messages don't cross namespace boundaries
- You cannot have duplicate namespace paths
Common Mistakes
// ❌ Missing @Namespace
@Controller()
export class BadGateway {
@Message({ event: 'test' })
handleTest() { ... }
}
// Warning: Unable to find namespace. Did you use @Namespace()?
// ❌ Empty namespace
@Namespace('')
@Controller()
export class BadGateway { ... }
// ✅ Correct
@Namespace('/my-namespace')
@Controller()
export class GoodGateway { ... }
@OnConnectionAttempt
The @OnConnectionAttempt decorator handles WebSocket connection attempts, allowing you to accept or reject connections based on authentication, authorization, or other criteria.
Signature
function OnConnectionAttempt(): MethodDecorator
Parameters
This decorator takes no parameters.
Handler Method Signature
async (
params: Record<string, unknown>,
request: Request
): Promise<boolean | void>
| Parameter | Type | Description |
|---|---|---|
params | Record<string, unknown> | Query parameters from the connection URL |
request | Request | The original HTTP upgrade request |
Behavior
- Called before a WebSocket connection is established
- Return
trueorundefinedto accept the connection - Return
falseto reject with 403 Forbidden - Throw an error to reject with 403 and error message
- Optional - if not defined, all connections are accepted
- Only one
@OnConnectionAttempthandler per namespace
Examples
@Namespace('/secure')
@Controller()
export class SecureGateway {
@OnConnectionAttempt()
async validateConnection(
params: Record<string, unknown>,
request: Request
): Promise<boolean> {
const token = params.token as string;
if (!token) {
return false; // Reject
}
return true; // Accept
}
}
// Client: ws://server.com/secure?token=abc123
@Namespace('/authenticated')
@Controller()
export class AuthenticatedGateway {
@Inject(AuthService)
private authService!: AuthService;
@OnConnectionAttempt()
async validateToken(params: Record<string, unknown>): Promise<boolean> {
const token = params.token as string;
try {
const user = await this.authService.verifyJWT(token);
// Check if user is active
if (!user.isActive) {
throw new Error('Account is inactive');
}
return true;
} catch (error) {
console.error('Auth failed:', error);
return false;
}
}
}
@Namespace('/admin')
@Controller()
export class AdminGateway {
@Inject(UserService)
private userService!: UserService;
@OnConnectionAttempt()
async checkAdminAccess(params: Record<string, unknown>): Promise<boolean> {
const userId = params.userId as string;
if (!userId) {
throw new Error('User ID required');
}
const user = await this.userService.findById(userId);
if (!user || user.role !== 'admin') {
throw new Error('Admin access required');
}
return true;
}
}
@Namespace('/internal')
@Controller()
export class InternalGateway {
private allowedIPs = new Set(['127.0.0.1', '192.168.1.1']);
@OnConnectionAttempt()
async checkIP(params: Record<string, unknown>, request: Request): Promise<boolean> {
const ip = request.headers.get('x-forwarded-for') ||
request.headers.get('x-real-ip') ||
'unknown';
if (!this.allowedIPs.has(ip)) {
throw new Error(`IP ${ip} not allowed`);
}
return true;
}
}
@OnConnectionAttempt()
async validateHeaders(
params: Record<string, unknown>,
request: Request
): Promise<boolean> {
const apiKey = request.headers.get('x-api-key');
const version = request.headers.get('x-client-version');
if (apiKey !== process.env.WS_API_KEY) {
throw new Error('Invalid API key');
}
// Check minimum client version
if (version && this.isVersionTooOld(version)) {
throw new Error('Please update your client');
}
return true;
}
Return Values
| Return Value | Behavior |
|---|---|
true | Accept connection |
false | Reject with 403 Forbidden |
undefined (void) | Accept connection |
| Thrown error | Reject with 403 and error message |
Common Patterns
@OnConnectionAttempt()
async validateConnection(params: Record<string, unknown>): Promise<boolean> {
// All async operations work
const isValid = await this.database.checkUser(params.userId);
const hasPermission = await this.checkPermissions(params.userId);
return isValid && hasPermission;
}
@OnConnectionAttempt()
async validateConnection(params: Record<string, unknown>): Promise<boolean> {
// Check 1: Token exists
if (!params.token) {
throw new Error('Token required');
}
// Check 2: Valid token
const user = await this.authService.verify(params.token as string);
if (!user) {
throw new Error('Invalid token');
}
// Check 3: User has access
if (!user.permissions.includes('websocket:connect')) {
throw new Error('Insufficient permissions');
}
return true;
}
@Message
The @Message decorator listens for incoming WebSocket messages with a specific event name.
Signature
function Message(options: MessageDecoratorOptions): MethodDecorator
interface MessageDecoratorOptions {
event: string;
validationSchema?: ValidationTypes.Schema;
}
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
event | string | Yes | The event name to listen for |
validationSchema | Schema | No | Zod schema for validating incoming message data |
Handler Method Signature
async (
data: unknown,
peer: Peer
): Promise<any>
| Parameter | Type | Description |
|---|---|---|
data | unknown (or typed with schema) | The message data sent by the client |
peer | Peer | The connected peer that sent the message |
Peer Object
interface Peer {
id: string; // Unique peer identifier
namespace?: string; // Namespace the peer is connected to
ip?: string; // Client IP address
send(message: unknown): void; // Send message to this peer
}
Behavior
- Listens for messages with
{ event: '...', data: {...} }structure - Can have multiple message handlers with different events
- Supports schema validation with Zod or other validators
- Handler return value can be used with
@Emit,@Broadcast, etc. - Invalid messages (validation errors) are logged and ignored
Examples
@Namespace('/chat')
@Controller()
export class ChatGateway {
@Message({ event: 'send-message' })
handleMessage(data: any, peer: Peer) {
console.log(`Message from ${peer.id}:`, data);
}
}
// Client sends:
// ws.send(JSON.stringify({
// event: 'send-message',
// data: { text: 'Hello!' }
// }));
import { z } from 'zod';
const ChatMessageSchema = z.object({
text: z.string().min(1).max(500),
username: z.string().min(3).max(20),
room: z.string().optional()
});
type ChatMessage = z.infer<typeof ChatMessageSchema>;
@Message({
event: 'send-message',
validationSchema: ChatMessageSchema
})
handleMessage(data: ChatMessage, peer: Peer) {
// data is validated and typed
console.log(`${data.username}: ${data.text}`);
if (data.room) {
this.broadcastToRoom(data.room, data);
}
}
@Namespace('/game')
@Controller()
export class GameGateway {
@Message({ event: 'player-move' })
handleMove(data: { x: number; y: number }, peer: Peer) {
// Handle player movement
}
@Message({ event: 'player-attack' })
handleAttack(data: { target: string }, peer: Peer) {
// Handle player attack
}
@Message({ event: 'player-chat' })
handleChat(data: { text: string }, peer: Peer) {
// Handle chat message
}
}
@Message({ event: 'action' })
handleAction(data: any, peer: Peer) {
console.log('Peer ID:', peer.id);
console.log('Namespace:', peer.namespace);
console.log('IP:', peer.ip);
// Send message directly to this peer
peer.send({
event: 'action-response',
data: { status: 'received' }
});
}
@Namespace('/chat')
@Controller()
export class ChatGateway {
@Inject(ChatService)
private chatService!: ChatService;
@Inject(UserService)
private userService!: UserService;
@Message({ event: 'send-message' })
async handleMessage(data: { text: string; userId: string }, peer: Peer) {
// Use injected services
const user = await this.userService.findById(data.userId);
await this.chatService.saveMessage({
text: data.text,
user: user.name,
peerId: peer.id
});
}
}
const UpdateProfileSchema = z.object({
userId: z.string().uuid(),
profile: z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().int().positive().max(120).optional(),
preferences: z.object({
theme: z.enum(['light', 'dark']),
notifications: z.boolean()
}).optional()
})
});
@Message({
event: 'update-profile',
validationSchema: UpdateProfileSchema
})
async handleUpdateProfile(
data: z.infer<typeof UpdateProfileSchema>,
peer: Peer
) {
await this.userService.updateProfile(data.userId, data.profile);
return { success: true };
}
Validation Error Handling
When validation fails, the message is rejected and error details are logged:
// Client sends invalid data:
ws.send(JSON.stringify({
event: 'send-message',
data: { text: '' } // Too short!
}));
// Server logs:
// "Websocket message validation error"
// [{ path: 'text', message: 'String must contain at least 1 character(s)' }]
Common Patterns
@Namespace('/game')
@Controller()
export class GameGateway {
private playerStates = new Map<string, PlayerState>();
@Message({ event: 'player-move' })
handleMove(data: { x: number; y: number }, peer: Peer) {
// Update player state
const state = this.playerStates.get(peer.id) || this.createInitialState();
state.position = { x: data.x, y: data.y };
this.playerStates.set(peer.id, state);
}
}
@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
};
}
}
@Emit
The @Emit decorator sends the handler's return value back to the client that sent the message.
Signature
function Emit(event: string): MethodDecorator
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
event | string | Yes | The event name for the emitted message |
Behavior
- Sends message only to the peer that triggered the handler
- Uses the handler's return value as message data
- Must be used with
@Message() - Executes after the handler completes
- Automatically wraps data in
{ event: '...', data: {...} }format
Examples
@Namespace('/chat')
@Controller()
export class ChatGateway {
@Message({ event: 'ping' })
@Emit('pong')
handlePing() {
return { timestamp: Date.now() };
}
}
// Client sends: { event: 'ping', data: {} }
// Client receives: { event: 'pong', data: { timestamp: 1234567890 } }
@Message({ event: 'get-user' })
@Emit('user-data')
async getUserData(data: { userId: string }) {
const user = await this.userService.findById(data.userId);
return {
id: user.id,
name: user.name,
status: user.status
};
}
// Client receives: { event: 'user-data', data: { id, name, status } }
// ❌ You cannot have multiple @Emit decorators
@Message({ event: 'test' })
@Emit('response-1')
@Emit('response-2')
handleTest() { ... }
// ✅ Instead, emit once and let client handle
@Message({ event: 'test' })
@Emit('response')
handleTest() {
return {
type: 'multiple',
data: [...]
};
}
@Message({ event: 'create-order' })
@Emit('order-created')
async createOrder(data: CreateOrderDto, peer: Peer) {
const order = await this.orderService.create(data);
// Return confirmation
return {
orderId: order.id,
status: 'created',
timestamp: new Date().toISOString()
};
}
// Client can await response
@Message({ event: 'query' })
@Emit('query-result')
async handleQuery(data: { query: string }) {
if (data.query === 'users') {
return await this.getUsers();
} else if (data.query === 'orders') {
return await this.getOrders();
}
return { error: 'Unknown query' };
}
Return Value Requirements
The handler must return a value (or Promise that resolves to a value):
// ✅ Good - Returns value
@Message({ event: 'test' })
@Emit('result')
handleTest() {
return { foo: 'bar' };
}
// ✅ Good - Async return
@Message({ event: 'test' })
@Emit('result')
async handleTest() {
const data = await this.fetchData();
return data;
}
// ⚠️ Warning - Returns void
@Message({ event: 'test' })
@Emit('result')
handleTest() {
// No return - client receives empty data
}
@Broadcast
The @Broadcast decorator sends the handler's return value to all connected peers in the namespace, including the sender.
Signature
function Broadcast(event: string): MethodDecorator
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
event | string | Yes | The event name for the broadcasted message |
Behavior
- Sends message to ALL peers in the namespace
- Includes the peer that triggered the handler
- Uses the handler's return value as message data
- Must be used with
@Message() - All peers receive the same message simultaneously
Examples
@Namespace('/chat')
@Controller()
export class ChatGateway {
@Message({ event: 'user-message' })
@Broadcast('new-message')
handleMessage(data: { text: string; user: string }) {
return {
text: data.text,
user: data.user,
timestamp: Date.now()
};
}
}
// When user A sends a message:
// - User A receives it
// - User B receives it
// - User C receives it
// ... all users receive the same message
@Message({ event: 'user-joined' })
@Broadcast('user-status')
handleUserJoined(data: { username: string; userId: string }) {
return {
type: 'joined',
username: data.username,
userId: data.userId,
timestamp: new Date().toISOString()
};
}
// All users see when someone joins
@Namespace('/game')
@Controller()
export class GameGateway {
@Message({ event: 'player-action' })
@Broadcast('game-update')
handleAction(data: { action: string; playerId: string }) {
// Update game state
const newState = this.gameEngine.processAction(data);
// Broadcast new state to all players
return {
state: newState,
lastAction: data.action,
timestamp: Date.now()
};
}
}
@Namespace('/notifications')
@Controller()
export class NotificationGateway {
@Message({ event: 'system-alert' })
@Broadcast('alert')
handleSystemAlert(data: { message: string; severity: string }) {
return {
type: 'system',
message: data.message,
severity: data.severity,
timestamp: Date.now()
};
}
}
// All connected clients receive the alert
@Message({ event: 'document-change' })
@Broadcast('document-updated')
handleDocumentChange(data: {
documentId: string;
changes: any[];
userId: string;
}) {
return {
documentId: data.documentId,
changes: data.changes,
userId: data.userId,
version: this.incrementVersion(data.documentId)
};
}
// All editors see changes in real-time
Broadcast vs Emit
// @Emit - Only sender receives response
@Message({ event: 'ping' })
@Emit('pong')
handlePing() { ... }
// @Broadcast - Everyone receives (including sender)
@Message({ event: 'announce' })
@Broadcast('announcement')
handleAnnounce() { ... }
Common Patterns
@Message({
event: 'post-message',
validationSchema: MessageSchema
})
@Broadcast('new-post')
async handlePost(data: MessageDto) {
// Save to database
await this.messageService.save(data);
// Broadcast to all
return {
id: generateId(),
...data,
createdAt: new Date()
};
}
@Message({ event: 'update' })
async handleUpdate(data: any, peer: Peer) {
// Process update
const result = await this.process(data);
// Manually broadcast if needed
if (result.shouldNotify) {
this.wsService.broadcast(peer, {
event: 'update-notification',
data: result
});
}
return result;
}
@BroadcastOthers
The @BroadcastOthers decorator sends the handler's return value to all connected peers in the namespace, excluding the sender.
Signature
function BroadcastOthers(event: string): MethodDecorator
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
event | string | Yes | The event name for the broadcasted message |
Behavior
- Sends message to ALL peers in the namespace EXCEPT the sender
- Uses the handler's return value as message data
- Must be used with
@Message() - Sender doesn't receive the broadcast (use
@Emitif needed)
Examples
@Namespace('/chat')
@Controller()
export class ChatGateway {
@Message({ event: 'typing' })
@BroadcastOthers('user-typing')
handleTyping(data: { username: string; isTyping: boolean }) {
return {
username: data.username,
isTyping: data.isTyping
};
}
}
// User A types:
// - User A: doesn't see their own typing indicator
// - User B: sees "User A is typing..."
// - User C: sees "User A is typing..."
@Namespace('/game')
@Controller()
export class GameGateway {
@Message({ event: 'move' })
@BroadcastOthers('player-moved')
handleMove(data: { x: number; y: number }, peer: Peer) {
return {
playerId: peer.id,
position: { x: data.x, y: data.y },
timestamp: Date.now()
};
}
}
// Player moves:
// - Moving player: sees their own movement locally
// - Other players: see the movement via broadcast
@Message({ event: 'cursor-move' })
@BroadcastOthers('cursor-update')
handleCursorMove(data: { x: number; y: number; userId: string }) {
return {
userId: data.userId,
position: { x: data.x, y: data.y }
};
}
// User moves cursor:
// - Moving user: sees own cursor locally
// - Other users: see cursor position update
@Message({ event: 'status-change' })
@BroadcastOthers('user-status-changed')
handleStatusChange(data: { status: 'online' | 'away' | 'busy' }, peer: Peer) {
return {
userId: peer.id,
status: data.status,
timestamp: new Date().toISOString()
};
}
// User changes status:
// - Status changing user: already knows their status
// - Other users: notified of the change
@Message({ event: 'select-item' })
@BroadcastOthers('item-selected')
handleSelection(data: { itemId: string; userId: string }) {
return {
itemId: data.itemId,
userId: data.userId,
action: 'selected'
};
}
// User selects item:
// - Selecting user: handles selection locally
// - Other users: see what was selected
Combining with @Emit
You can use both decorators to send different messages:
@Message({ event: 'action' })
@Emit('action-confirmed') // Sender gets confirmation
@BroadcastOthers('user-action') // Others get notification
handleAction(data: any, peer: Peer) {
return {
action: data.action,
userId: peer.id
};
}
// Sender receives: { event: 'action-confirmed', data: {...} }
// Others receive: { event: 'user-action', data: {...} }
Common Patterns
@Message({ event: 'join-room' })
@BroadcastOthers('user-joined')
handleJoinRoom(data: { roomId: string; username: string }) {
// Add user to room
this.addToRoom(data.roomId, data.username);
// Notify others
return {
roomId: data.roomId,
username: data.username,
message: `${data.username} joined the room`
};
}
@Message({ event: 'document-edit' })
@BroadcastOthers('edit-applied')
handleEdit(data: {
documentId: string;
edit: any;
userId: string;
}) {
// Apply edit
this.applyEdit(data.documentId, data.edit);
// Notify others (editor already has it locally)
return {
documentId: data.documentId,
edit: data.edit,
userId: data.userId
};
}
Decorator Combinations
You can combine decorators to create powerful patterns:
@Message + @Emit
Send response back to sender only:
@Message({ event: 'request' })
@Emit('response')
handleRequest(data: any) {
return { result: 'processed' };
}
@Message + @Broadcast
Notify everyone including sender:
@Message({ event: 'update' })
@Broadcast('updated')
handleUpdate(data: any) {
return { updated: true };
}
@Message + @BroadcastOthers
Notify everyone except sender:
@Message({ event: 'action' })
@BroadcastOthers('notification')
handleAction(data: any) {
return { action: 'completed' };
}
@Message + @Emit + @BroadcastOthers
Send different messages to sender and others:
@Message({ event: 'post' })
@Emit('post-confirmed')
@BroadcastOthers('new-post')
handlePost(data: PostDto) {
return data;
}
// Sender: { event: 'post-confirmed', data: {...} }
// Others: { event: 'new-post', data: {...} }
Multiple Handlers Same Namespace
@Namespace('/app')
@Controller()
export class AppGateway {
@Message({ event: 'ping' })
@Emit('pong')
handlePing() {
return { time: Date.now() };
}
@Message({ event: 'broadcast' })
@Broadcast('message')
handleBroadcast(data: any) {
return data;
}
@Message({ event: 'notify-others' })
@BroadcastOthers('notification')
handleNotify(data: any) {
return data;
}
}
Event Naming
// ✅ Good - Descriptive, namespaced
@Message({ event: 'chat:send-message' })
@Message({ event: 'game:player-move' })
@Message({ event: 'document:edit' })
// ❌ Bad - Generic, unclear
@Message({ event: 'message' })
@Message({ event: 'update' })
@Message({ event: 'data' })
Type Safety
// ✅ Good - Typed with schema
const Schema = z.object({ ... });
type Data = z.infer<typeof Schema>;
@Message({
event: 'action',
validationSchema: Schema
})
handleAction(data: Data, peer: Peer) { ... }
// ❌ Bad - Untyped
@Message({ event: 'action' })
handleAction(data: any, peer: Peer) { ... }
Error Handling
// ✅ Good - Graceful error handling
@Message({ event: 'risky' })
async handleRisky(data: any) {
try {
return await this.process(data);
} catch (error) {
return { error: error.message };
}
}
// ❌ Bad - Unhandled errors
@Message({ event: 'risky' })
async handleRisky(data: any) {
return await this.process(data); // May throw
}
Single Responsibility
// ✅ Good - One concern per handler
@Message({ event: 'create-order' })
async createOrder(data: OrderDto) { ... }
@Message({ event: 'cancel-order' })
async cancelOrder(data: { orderId: string }) { ... }
// ❌ Bad - Multiple concerns
@Message({ event: 'order-action' })
async handleOrder(data: any) {
if (data.action === 'create') { ... }
else if (data.action === 'cancel') { ... }
}