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/decorators';
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/decorators';
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/decorators';
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/decorators';
import { Inject } from '@asenajs/asena/decorators/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/decorators';
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/decorators';
import { Inject } from '@asenajs/asena/decorators/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/decorators';
import { Post } from '@asenajs/asena/decorators/http';
import { Inject } from '@asenajs/asena/decorators/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'
}));
}
}
}Multi-Pod WebSocket
When running multiple server instances (pods), WebSocket messages published on one pod won't reach clients connected to other pods by default. Asena solves this with the WebSocket Transport abstraction.
How Transport Works
By default, Asena uses BunLocalTransport which calls server.publish() directly — zero overhead for single-pod deployments. For multi-pod setups, you can plug in a transport (like RedisTransport) that relays messages across instances via a message broker.
Setting Up RedisTransport
Configure the transport in your @Config class:
import { Config } from '@asenajs/asena/decorators';
import { Inject } from '@asenajs/asena/decorators/ioc';
import { ConfigService } from '@asenajs/hono-adapter'; // or '@asenajs/ergenecore'
import { RedisTransport } from '@asenajs/asena-redis';
@Config()
export class AppConfig extends ConfigService {
@Inject('AppRedis')
private redis: AppRedis;
public transport() {
return new RedisTransport(this.redis);
}
}Or without an existing Redis service:
public transport() {
return new RedisTransport({ url: 'redis://localhost:6379' });
}How It Works
Each server instance gets a unique pod ID. When a WebSocket message is published:
- The message is delivered locally via
server.publish() - The message is sent to Redis pub/sub with the originating pod ID
- Other pods receive the message and deliver it to their local sockets
- Messages from the same pod are deduplicated automatically
No Code Changes
Your WebSocket services, Ulak messaging, and room management work exactly the same with RedisTransport. The transport layer is transparent — just configure it in @Config and multi-pod support is enabled.
Custom Transports
The WebSocketTransport interface (from @asenajs/asena) defines publish(), init(), and destroy() methods. You can implement custom transports for other message brokers like NATS or RabbitMQ.
For full RedisTransport reference and Redis service setup, see Redis Package.
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