WebSocket
Asena provides built-in WebSocket support with namespace management, allowing you to create real-time, bidirectional communication between clients and your server.
The @WebSocket decorator and its namespace.
Open, message, close and drain callbacks.
03Everything a connected socket can do.
04Built-in room membership and broadcasting.
05Guarding and enriching the upgrade handshake.
06Broadcasting across more than one server instance.
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 of this namespace (managed automatically)this.to(room, data)- Broadcast from service levelthis.in(data)- Broadcast to all connected clients
Rooms are not enumerable
Room membership lives in Bun's pub/sub topics, which cannot be listed. There is no this.rooms map and no getSocketsByRoom(). If you need a member count or roster, keep your own registry - add the socket in onOpen, remove it in onClose.
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:
ws.publish() excludes the sender
ws.publish(), ws.publishText() and ws.publishBinary() deliver to every subscriber of the topic except the socket that called them. If the sender should see the message too, send it explicitly:
const payload = JSON.stringify({ type: 'message', text });
ws.publish(room, payload);
ws.send(payload); // the sender, who publish() left outThe service-level this.to() / this.in() are the opposite: they reach every subscriber, sender included. Pick by whether the sender is meant to be in the audience.
This rule does not change when you configure a transport(). Before @asenajs/asena 0.10.1 it did - a transport routed ws.publish() through server.publish(), which cannot exclude anything, so adding one for multi-pod delivery silently started echoing every publish back to its sender.
protected async onMessage(ws: Socket<ChatData>, message: string): Promise<void> {
const room = ws.data?.values.room || 'general';
const username = ws.data?.values.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?.values.room || 'general';
const username = ws.data?.values.username;
// Notify room before leaving
ws.publish(room, JSON.stringify({
type: 'user_left',
username
}));
// Unsubscribe - Asena handles cleanup automatically
ws.unsubscribe(room);
}Counting Room Members
Bun's pub/sub topics are write-only - you can publish to a room but not enumerate it. To report a member count, track it yourself:
@WebSocket({ path: '/ws/chat', name: 'ChatSocket' })
export class ChatSocket extends AsenaWebSocketService<ChatData> {
private roomMembers = new Map<string, Set<string>>();
protected async onOpen(ws: Socket<ChatData>): Promise<void> {
const room = ws.data?.values.room || 'general';
ws.subscribe(room);
if (!this.roomMembers.has(room)) this.roomMembers.set(room, new Set());
this.roomMembers.get(room).add(ws.id);
}
protected async onClose(ws: Socket<ChatData>): Promise<void> {
const room = ws.data?.values.room || 'general';
this.roomMembers.get(room)?.delete(ws.id);
}
protected async onMessage(ws: Socket<ChatData>, message: Buffer | string): Promise<void> {
const room = ws.data?.values.room || 'general';
ws.send(JSON.stringify({
type: 'room_info',
totalUsers: this.roomMembers.get(room)?.size ?? 0
}));
}
}Single-pod only
This registry lives in one process. In a multi-pod deployment each pod sees only its own sockets - use a shared store (Redis) if the count must be global.
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) {
// Pass the object as-is - this.in()/this.to() serialize it for you
this.in(notification);
}
// 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, notification);
}
// Example: Admin sends announcement to a room
sendAnnouncement(room: string, message: string) {
this.to(room, {
type: 'announcement',
message,
timestamp: new Date().toISOString()
});
}
}Do not pre-stringify for this.to() / this.in()
These two serialize their payload internally. Passing a string that is already JSON gets encoded a second time, so the client receives "{\"type\":\"announcement\"}" - a quoted string, not an object.
The socket-level methods behave the opposite way: ws.send() and ws.publish() pass the value through untouched, so those do take a string.
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, {
type: 'system_message',
message,
timestamp: new Date().toISOString()
});
}
async notifyAllUsers(message: string) {
// Broadcast to all connected clients
this.chatSocket.in({
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?.values.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?.values.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}`, {
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', {
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';
@Controller('/admin')
export class AdminController {
@Inject(NotificationSocket)
private notificationSocket: NotificationSocket;
@Post('/announcement')
async sendAnnouncement(context: Context) {
const { message } = await context.getBody<{ message: string }>();
// Broadcast to all connected clients
this.notificationSocket.to('announcements', {
type: 'announcement',
message,
timestamp: new Date().toISOString()
});
return context.send({ 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()forthis.to(), via the socket's ownws.publish()forsocket.publish()(which is what keeps the sender excluded there) - 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.
A transport changes where a message can reach, never who receives it locally. Adding one does not start or stop delivering a socket's own publish back to it.
Custom Transports
The WebSocketTransport interface (from @asenajs/asena) defines a required publish() plus optional publishRemote(), init() and destroy() hooks. You can implement custom transports for other message brokers like NATS or RabbitMQ.
publish() is responsible for both local and remote delivery — server.publish() plus the broker — and is what the service-level this.to() uses.
publishRemote() is the wire half alone: the broker publish with no server.publish(). socket.publish() calls it after having done local delivery itself through Bun's socket-level ws.publish(), the only primitive that can leave the publishing socket out. A transport that does local delivery again here would both duplicate the message locally and echo it back to the sender.
export class MyTransport implements WebSocketTransport {
public publish(topic: string, data: string | ArrayBuffer | ArrayBufferView): void {
this.server.publish(topic, data); // local
this.publishRemote(topic, data); // remote
}
public publishRemote(topic: string, data: string | ArrayBuffer | ArrayBufferView): void {
this.broker.publish(topic, envelope(data, this.podId)); // remote only
}
}init(server) is called during startup, before any connection is accepted. destroy() is called from server.stop(), when the adapter shuts the WebSocket layer down — under a 5s ceiling, with failures logged and stepped over so one unreachable broker cannot hold the shutdown open.
Implement publishRemote() in custom transports
It is optional only for backwards compatibility. A transport without it keeps the pre-0.10.1 behaviour — socket.publish() falls back to publish(), so the message is delivered to the sender as well — and the adapter warns once at startup. The fallback is removed in the next major version.
It is optional rather than required because the alternative reading of a missing method, "do not forward anywhere", would drop every cross-pod message in silence. A wrong-but-delivered message beats a lost one.
The same warning appears if the adapter is 3.1+ while @asenajs/asena is still 0.10.0 — the peer range ^0.10.0 permits that pairing, and on it BunLocalTransport has no publishRemote() either. Upgrading core to 0.10.1 clears it.
destroy() was never called before 0.10.0
The hook was documented as "called during server shutdown" and had zero call sites anywhere in the framework. A Redis-backed multi-pod setup therefore leaked a subscriber connection (with a live channel subscription) and a publisher connection on every server.stop() — enough for a test suite doing twenty stop/start cycles, or a pod under a rolling deploy, to exhaust the broker's connection limit. Both adapters now reach it.
If your custom transport has been relying on never being torn down, make destroy() idempotent: the transport reference is deliberately kept after teardown, so a restarted server reuses the same object rather than silently downgrading to the local transport.
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 - pass objects, they are serialized for you
this.to('room-1', { text: 'Message to room' }); // Broadcast to specific room
this.in({ text: 'Message to all' }); // Broadcast to all clients
// ✅ Good: Use publish from socket level - this one takes the raw payload
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: leave cleanup alone and use onClose for your own state
protected async onClose(ws: Socket) {
await this.presenceService.markOffline(ws.data.id);
// Asena automatically:
// - Removes the socket from this.sockets
// - Unsubscribes it from the topics it manages
// - Handles connection termination
}
// ❌ Bad: Manual cleanup (unnecessary and error-prone)
protected async onClose(ws: Socket) {
this.sockets.delete(ws.id); // Asena does this!
ws.unsubscribe('room-1'); // Bun drops every subscription when the socket closes!
}Room subscriptions need no cleanup at all: they live in Bun's pub/sub topics and disappear with the connection. There is no this.rooms map to sweep — see Built-in Room Management above.
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 } from '@asenajs/asena/decorators';
import { Inject } from '@asenajs/asena/decorators/ioc';
import { ulak } from '@asenajs/asena/messaging';
import type { Ulak } from '@asenajs/asena/messaging';
@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
- Ulak - WebSocket Messaging System - Break circular dependencies
- Services - Reaching sockets from your business logic
- Middleware - Guarding the upgrade handshake
- Dependency Injection - Wiring WebSocket services
- Ergenecore Adapter - Ergenecore-specific features
- Hono Adapter - Hono-specific features