WebSocket
Asena provides built-in WebSocket support with namespace management, allowing you to create real-time, bidirectional communication between clients and your server.
Creating a WebSocket Service
Create a WebSocket service by extending AsenaWebSocketService and decorating it with @WebSocket:
import { WebSocket } from '@asenajs/asena/server';
import { AsenaWebSocketService, type Socket } from '@asenajs/asena/web-socket';
@WebSocket({ path: '/chat', name: 'ChatSocket' })
export class ChatSocket extends AsenaWebSocketService<void> {
protected async onOpen(ws: Socket<void>): Promise<void> {
console.log('Client connected:', ws.id);
ws.send('Welcome to chat!');
}
protected async onMessage(ws: Socket<void>, message: string): Promise<void> {
console.log('Received:', message);
ws.send(`Echo: ${message}`);
}
protected async onClose(ws: Socket<void>): Promise<void> {
console.log('Client disconnected:', ws.id);
}
}WebSocket Lifecycle Methods
onOpen(socket)
Called when a client connects.
protected async onOpen(ws: Socket): Promise<void> {
console.log(`New connection: ${ws.id}`);
ws.send(JSON.stringify({ type: 'welcome', message: 'Connected!' }));
}onMessage(socket, message)
Called when a message is received.
protected async onMessage(ws: Socket, message: string): Promise<void> {
const data = JSON.parse(message);
if (data.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
}
}onClose(socket)
Called when a client disconnects.
protected async onClose(ws: Socket): Promise<void> {
console.log(`Client disconnected: ${ws.id}`);
// Cleanup logic
}Socket API
ws.send(message)
Send a message to the client.
ws.send('Hello client!');
ws.send(JSON.stringify({ type: 'update', data: { count: 42 } }));ws.id
Unique identifier for the socket connection.
console.log(`Socket ID: ${ws.id}`);ws.data
Custom data attached to the socket (typed). It holds 3 values.
export interface WebSocketData<T = any> {
values: T;
id: string;
path: string;
}Asena automatically assigns the id and path fields. The values: T field is reserved for user-managed data. You can update it through context.setWebSocketValue, and it will automatically sync to socket.data.values
interface UserData {
userId: string;
username: string;
}
@WebSocket({ path: '/chat', name: 'ChatSocket' })
export class ChatSocket extends AsenaWebSocketService<UserData> {
protected async onOpen(ws: Socket<UserData>): Promise<void> {
console.log(`User connected: ${ws.data.values.username}`);
}
}Built-in Room Management
Asena's Built-in Features
Asena provides automatic room management with built-in pub/sub pattern. You don't need to manually manage Map<string, Socket[]> or track connections yourself - Asena handles everything for you!
Key built-in features:
ws.subscribe(room)- Automatically joins room and tracks membershipws.publish(room, data)- Broadcasts to all room subscribersws.unsubscribe(room)- Leaves room with automatic cleanupthis.sockets- All connected sockets (managed automatically)this.rooms- All rooms and their members (managed automatically)this.to(room, data)- Broadcast from service levelthis.in(data)- Broadcast to all connected clients
Subscribing to Rooms
When a client connects, use subscribe() to join a room. Asena automatically tracks the socket in that room:
interface ChatData {
username: string;
room: string;
}
@WebSocket({ path: '/chat', name: 'ChatSocket' })
export class ChatSocket extends AsenaWebSocketService<ChatData> {
protected async onOpen(ws: Socket<ChatData>): Promise<void> {
const room = ws.data.values.room || 'general';
const username = ws.data.values.username || 'Anonymous';
// Subscribe to room - Asena tracks this automatically
ws.subscribe(room);
// Welcome the user
ws.send(JSON.stringify({
type: 'welcome',
message: `Welcome to ${room}, ${username}!`
}));
// Notify others in the room using publish
ws.publish(room, JSON.stringify({
type: 'user_joined',
username,
timestamp: new Date().toISOString()
}));
}
}Publishing Messages
Use ws.publish() to broadcast messages to all subscribers of a room:
protected async onMessage(ws: Socket<ChatData>, message: string): Promise<void> {
const room = ws.data?.room || 'general';
const username = ws.data?.username;
try {
const data = JSON.parse(message);
if (data.type === 'message') {
// Broadcast to all subscribers in the room
ws.publish(room, JSON.stringify({
type: 'message',
username,
message: data.message,
timestamp: new Date().toISOString()
}));
}
} catch (error) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format' }));
}
}Unsubscribing from Rooms
When a client disconnects or leaves a room, use unsubscribe():
protected async onClose(ws: Socket<ChatData>): Promise<void> {
const room = ws.data?.room || 'general';
const username = ws.data?.username;
// Notify room before leaving
ws.publish(room, JSON.stringify({
type: 'user_left',
username
}));
// Unsubscribe - Asena handles cleanup automatically
ws.unsubscribe(room);
}Accessing Room Members
You can access all sockets in a room using the built-in this.rooms map:
protected async onMessage(ws: Socket<ChatData>, message: string): Promise<void> {
const room = ws.data?.room || 'general';
// Get all sockets in this room
const roomMembers = this.getSocketsByRoom(room);
ws.send(JSON.stringify({
type: 'room_info',
totalUsers: roomMembers?.length || 0
}));
}Broadcasting
Broadcast to All Clients
Use the built-in this.in() method to broadcast to all connected clients:
@WebSocket({ path: '/notifications', name: 'NotificationSocket' })
export class NotificationSocket extends AsenaWebSocketService<void> {
// No need to manually track sockets - Asena does it for you!
// Public method to broadcast notifications
broadcastNotification(notification: any) {
const message = JSON.stringify(notification);
// Broadcast to all connected clients
this.in(message);
}
// You can also access all sockets via this.sockets (built-in)
getConnectedUsers() {
return Array.from(this.sockets.keys());
}
}Broadcast to Specific Room
Use this.to(room, data) to broadcast to a specific room from the service level:
@WebSocket({ path: '/chat', name: 'ChatSocket' })
export class ChatSocket extends AsenaWebSocketService<{ room: string }> {
// Broadcast to a specific room
notifyRoom(room: string, notification: any) {
this.to(room, JSON.stringify(notification));
}
// Example: Admin sends announcement to a room
sendAnnouncement(room: string, message: string) {
this.to(room, JSON.stringify({
type: 'announcement',
message,
timestamp: new Date().toISOString()
}));
}
}Private Messages
Send a message to a specific user using their socket ID:
sendPrivateMessage(targetSocketId: string, message: string) {
const targetSocket = this.sockets.get(targetSocketId);
if (targetSocket) {
targetSocket.send(JSON.stringify({
type: 'private_message',
message
}));
}
}WebSocket Middleware
Just like controllers, WebSocket services support middleware! This is the recommended way to handle authentication, logging, and rate limiting.
Creating WebSocket Middleware
import { Middleware } from '@asenajs/asena/server';
import type { Context, MiddlewareService } from '@asenajs/ergenecore';
@Middleware()
export class WsAuthMiddleware implements MiddlewareService {
async handle(context: Context, next: () => Promise<void>): Promise<boolean | Response> {
// Check query params for token
const url = new URL(context.req.url);
const token = url.searchParams.get('token');
// Or check Authorization header
const authHeader = context.req.headers.get('Authorization');
const tokenFromHeader = authHeader?.startsWith('Bearer ')
? authHeader.substring(7)
: null;
const finalToken = token || tokenFromHeader;
if (!finalToken) {
return context.send({ error: 'Unauthorized' }, 401);
}
// Verify token (replace with your auth logic)
if (finalToken !== 'valid-token') {
return context.send({ error: 'Invalid token' }, 401);
}
// Pass user data to WebSocket using setWebSocketValue
context.setWebSocketValue({
userId: '123',
username: 'john_doe'
});
await next();
}
}context.setWebSocketValue()
This is the key method! Use context.setWebSocketValue() in middleware to pass authenticated user data to your WebSocket service. The data will be available in ws.data.values.
Using Middleware in WebSocket
import { WebSocket } from '@asenajs/asena/server';
import { AsenaWebSocketService, type Socket } from '@asenajs/asena/web-socket';
import { WsAuthMiddleware } from '../middlewares/WsAuthMiddleware';
interface UserData {
userId: string;
username: string;
}
@WebSocket({
path: '/private',
middlewares: [WsAuthMiddleware], // Add middleware here
name: 'PrivateSocket'
})
export class PrivateSocket extends AsenaWebSocketService<UserData> {
protected async onOpen(ws: Socket<UserData>): Promise<void> {
// Access authenticated user data from middleware
const { userId, username } = ws.data.values;
console.log(`Authenticated user connected: ${username}`);
ws.send(JSON.stringify({
type: 'authenticated',
userId,
username
}));
}
protected async onMessage(ws: Socket<UserData>, message: string): Promise<void> {
const { username } = ws.data.values;
console.log(`Message from ${username}:`, message);
}
}Multiple Middleware
You can use multiple middleware, just like in controllers:
import { WsLoggingMiddleware } from '../middlewares/WsLoggingMiddleware';
import { WsAuthMiddleware } from '../middlewares/WsAuthMiddleware';
import { WsRateLimitMiddleware } from '../middlewares/WsRateLimitMiddleware';
@WebSocket({
path: '/admin',
middlewares: [
WsLoggingMiddleware, // Runs first
WsAuthMiddleware, // Then authentication
WsRateLimitMiddleware // Finally rate limiting
],
name: 'AdminSocket'
})
export class AdminSocket extends AsenaWebSocketService<AdminData> {
protected async onOpen(ws: Socket<AdminData>): Promise<void> {
// Only authenticated and rate-limited users reach here
const userData = ws.data.values;
ws.send(JSON.stringify({
type: 'admin-welcome',
user: userData,
permissions: ['read', 'write', 'delete']
}));
}
}Middleware Execution Order
Middleware executes in the order specified in the array, before the WebSocket connection is established. If any middleware returns a response or doesn't call next(), the connection is rejected.
Client-Side Example
Connect with authentication:
// With query parameter
const ws = new WebSocket('ws://localhost:3000/private?token=valid-token');
// Or with Authorization header (if your WebSocket client supports it)
const ws = new WebSocket('ws://localhost:3000/private', {
headers: {
'Authorization': 'Bearer valid-token'
}
});Using WebSocket in Services
You can inject a WebSocket service into other services to send messages from anywhere in your application:
import { Service } from '@asenajs/asena/server';
import { Inject } from '@asenajs/asena/ioc';
import { ChatSocket } from './ChatSocket';
@Service('NotificationService')
export class NotificationService {
@Inject(ChatSocket)
private chatSocket: ChatSocket;
async sendSystemMessage(room: string, message: string) {
// Broadcast from outside the WebSocket service
this.chatSocket.to(room, JSON.stringify({
type: 'system_message',
message,
timestamp: new Date().toISOString()
}));
}
async notifyAllUsers(message: string) {
// Broadcast to all connected clients
this.chatSocket.in(JSON.stringify({
type: 'notification',
message
}));
}
}Service Injection
This is a powerful pattern! You can send WebSocket messages from controllers, services, or background jobs by injecting the WebSocket service.
Real-World Example: Notification System
Here's a simple notification system showing how to use WebSocket with service injection:
WebSocket Service
import { WebSocket } from '@asenajs/asena/server';
import { AsenaWebSocketService, type Socket } from '@asenajs/asena/web-socket';
interface NotificationData {
userId: string;
}
@WebSocket({ path: '/ws/notifications', name: 'NotificationSocket' })
export class NotificationSocket extends AsenaWebSocketService<NotificationData> {
protected async onOpen(ws: Socket<NotificationData>): Promise<void> {
const userId = ws.data?.userId;
// Subscribe to user's personal notification channel
ws.subscribe(`user:${userId}`);
// Subscribe to global announcements
ws.subscribe('announcements');
// Send welcome message
ws.send(JSON.stringify({
type: 'connected',
message: 'Connected to notification system'
}));
}
protected async onClose(ws: Socket<NotificationData>): Promise<void> {
const userId = ws.data?.userId;
// Unsubscribe from channels - Asena handles cleanup
ws.unsubscribe(`user:${userId}`);
ws.unsubscribe('announcements');
}
protected async onMessage(ws: Socket<NotificationData>, message: string): Promise<void> {
try {
const data = JSON.parse(message);
if (data.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
}
} catch (error) {
console.error('Invalid message format');
}
}
}Using WebSocket from a Service
Now you can send notifications from any service in your application:
import { Service } from '@asenajs/asena/server';
import { Inject } from '@asenajs/asena/ioc';
import { NotificationSocket } from '../websocket/NotificationSocket';
@Service('UserService')
export class UserService {
@Inject(NotificationSocket)
private notificationSocket: NotificationSocket;
async updateUserProfile(userId: string, data: any): Promise<void> {
// ... update user in database
// Notify the user via WebSocket
this.notificationSocket.to(`user:${userId}`, JSON.stringify({
type: 'profile_updated',
message: 'Your profile has been updated',
timestamp: new Date().toISOString()
}));
}
async sendGlobalAnnouncement(message: string): Promise<void> {
// Broadcast to all users subscribed to announcements
this.notificationSocket.to('announcements', JSON.stringify({
type: 'announcement',
message,
timestamp: new Date().toISOString()
}));
}
}Using from a Controller
You can also trigger notifications from HTTP endpoints:
import { Controller } from '@asenajs/asena/server';
import { Post } from '@asenajs/asena/web';
import { Inject } from '@asenajs/asena/ioc';
import type { Context } from '@asenajs/ergenecore/types';
@Controller('/admin')
export class AdminController {
@Inject(NotificationSocket)
private notificationSocket: NotificationSocket;
@Post('/announcement')
async sendAnnouncement(context: Context) {
const { message } = await context.getBody();
// Broadcast to all connected clients
this.notificationSocket.to('announcements', JSON.stringify({
type: 'announcement',
message,
timestamp: new Date().toISOString()
}));
return context.json({ success: true });
}
}Key Takeaways
This example demonstrates:
- Built-in room management with
subscribe()/unsubscribe() - Service injection to send WebSocket messages from anywhere
- Multiple channels (user-specific and global)
- Controller integration for HTTP → WebSocket communication
- Asena handles all socket/room tracking automatically
Error Handling
@WebSocket({ path: '/chat', name: 'ChatSocket' })
export class ChatSocket extends AsenaWebSocketService<void> {
protected async onMessage(ws: Socket<void>, message: string): Promise<void> {
try {
const data = JSON.parse(message);
// Process message
if (!data.type) {
throw new Error('Message type is required');
}
// Handle different message types
switch (data.type) {
case 'ping':
ws.send(JSON.stringify({ type: 'pong' }));
break;
default:
throw new Error(`Unknown message type: ${data.type}`);
}
} catch (error) {
ws.send(JSON.stringify({
type: 'error',
message: error instanceof Error ? error.message : 'Unknown error'
}));
}
}
}Best Practices
1. Use Built-in Room Management
// ✅ Good: Use Asena's built-in subscribe/unsubscribe
protected async onOpen(ws: Socket) {
ws.subscribe('room-1');
ws.publish('room-1', 'Hello room!');
}
protected async onClose(ws: Socket) {
ws.unsubscribe('room-1');
}
// ❌ Bad: Manually managing rooms with Map
private rooms = new Map<string, Set<Socket>>(); // Don't do this!Avoid Manual Room Management
Asena automatically tracks sockets in rooms when you use subscribe() and unsubscribe(). Manual Map-based room management can lead to memory leaks and synchronization issues.
2. Use Broadcasting Methods
// ✅ Good: Use built-in broadcasting
this.to('room-1', 'Message to room'); // Broadcast to specific room
this.in('Message to all'); // Broadcast to all clients
// ✅ Good: Use publish from socket level
ws.publish('room-1', 'Message from user');
// ❌ Bad: Manual iteration over sockets
for (const socket of this.sockets.values()) {
socket.send(message); // Inefficient and error-prone
}3. Let Asena Handle Cleanup
// ✅ Good: Asena handles cleanup automatically
protected async onClose(ws: Socket) {
// Just unsubscribe from rooms
ws.unsubscribe('room-1');
// Asena automatically:
// - Removes socket from this.sockets
// - Cleans up room references
// - Handles connection termination
}
// ❌ Bad: Manual cleanup (unnecessary and error-prone)
protected async onClose(ws: Socket) {
this.sockets.delete(ws.id); // Asena does this!
this.rooms.forEach(r => r.delete(ws)); // Asena does this too!
}Breaking Circular Dependencies with Ulak
When building complex applications, you may encounter circular dependency issues when:
- WebSocket services need to inject business services for domain logic
- Business services need to inject WebSocket services to send real-time updates
Circular Dependency Problem
// ❌ This creates a circular dependency
@WebSocket('/notifications')
export class NotificationWebSocket extends AsenaWebSocketService<{}> {
@Inject(UserService) // WebSocket needs service
private userService: UserService;
}
@Service('UserService')
export class UserService {
@Inject(NotificationWebSocket) // ❌ Service needs WebSocket - CIRCULAR!
private notificationWs: NotificationWebSocket;
}The Solution: Ulak Message Broker
Ulak (Turkish: Messenger/Courier) is Asena's centralized WebSocket message broker that breaks this circular dependency by acting as a mediator between services and WebSocket connections.
// ✅ No circular dependency with Ulak
import { Service, Inject, ulak } from '@asenajs/asena';
import type { Ulak } from '@asenajs/asena';
@Service('UserService')
export class UserService {
// Inject scoped Ulak namespace instead of WebSocket service
@Inject(ulak('/notifications'))
private notifications: Ulak.NameSpace<'/notifications'>;
async createUser(name: string, email: string) {
const user = await this.saveUser(name, email);
// Send messages to WebSocket clients without injecting the WebSocket service
await this.notifications.broadcast({
type: 'user_created',
user
});
return user;
}
async notifyUser(userId: string, message: string) {
// Send to specific room
await this.notifications.to(`user:${userId}`, {
type: 'notification',
message
});
}
private async saveUser(name: string, email: string) {
// Database logic
return { id: '123', name, email };
}
}When to Use Ulak
Use Ulak when you need to:
- Send WebSocket messages from services, controllers, or background jobs
- Avoid circular dependencies between WebSocket handlers and domain services
- Broadcast to multiple namespaces from a single service
- Build scalable real-time features with clean separation of concerns
Continue using direct WebSocket injection (this guide's approach) when:
- You only need simple, one-way communication patterns
- You're not facing circular dependency issues
- Your WebSocket logic is self-contained
Ulak Key Features
- Three injection styles: Scoped namespace (recommended), expression-based, or direct Ulak injection
- Unified API:
broadcast(),to(),toSocket(),toMany()for messaging - Error handling: Structured
UlakErrorwith specific error codes - Bulk operations: Send multiple messages efficiently with
bulkSend()
For complete documentation, see Ulak - WebSocket Messaging System.
Related Documentation
- Ulak - WebSocket Messaging System - Break circular dependencies
- Ergenecore Adapter
- Hono Adapter
- Services
- Dependency Injection
Next Steps:
- Build a real-time application
- Explore Services
- Learn about Middleware