WebSocket Chat Application
This example shows how to build a real-time chat application with OneBun WebSocket Gateway: gateway, module, application config, and two client options (native and Socket.IO).
Overview
We'll create a chat application with:
- Multiple chat rooms
- User authentication
- Message broadcasting
- Room management
Project structure
src/
├── chat.gateway.ts # WebSocket gateway
├── chat.service.ts # Business logic
├── chat.module.ts # Module definition
├── auth.guard.ts # Custom guard
└── index.ts # Application entryStep 1: Create the gateway
Define the WebSocket gateway with connection and room handlers. Use @WebSocketGateway({ path: '/chat' }) so clients connect to /chat.
// src/chat.gateway.ts
import {
WebSocketGateway,
BaseWebSocketGateway,
OnConnect,
OnDisconnect,
OnJoinRoom,
OnLeaveRoom,
OnMessage,
Client,
MessageData,
RoomName,
PatternParams,
UseWsGuards,
} from '@onebun/core';
import type { WsClientData } from '@onebun/core';
import { ChatService } from './chat.service';
import { ChatAuthGuard } from './auth.guard';
interface ChatMessage {
text: string;
}
@WebSocketGateway({ path: '/chat' })
export class ChatGateway extends BaseWebSocketGateway {
constructor(private chatService: ChatService) {
super();
}
@OnConnect()
async handleConnect(@Client() client: WsClientData) {
this.logger.info(`Client ${client.id} connected`);
// Send welcome message
return {
event: 'welcome',
data: {
message: 'Welcome to the chat!',
clientId: client.id,
timestamp: Date.now(),
},
};
}
@OnDisconnect()
async handleDisconnect(@Client() client: WsClientData) {
this.logger.info(`Client ${client.id} disconnected`);
// Notify all rooms the client was in
for (const room of client.rooms) {
this.emitToRoom(room, 'user:left', {
userId: client.id,
room,
});
}
}
@OnJoinRoom('room:{roomId}')
async handleJoinRoom(
@Client() client: WsClientData,
@RoomName() room: string,
@PatternParams() params: { roomId: string },
) {
this.logger.info(`Client ${client.id} joining room ${params.roomId}`);
// Add to room
await this.joinRoom(client.id, room);
// Notify others in the room
this.emitToRoom(room, 'user:joined', {
userId: client.id,
room,
}, [client.id]); // Exclude the joining user
// Get message history
const history = await this.chatService.getMessageHistory(params.roomId);
return {
event: 'room:joined',
data: {
room: params.roomId,
history,
users: await this.getClientsInRoom(room),
},
};
}
@OnLeaveRoom('room:{roomId}')
async handleLeaveRoom(
@Client() client: WsClientData,
@RoomName() room: string,
@PatternParams() params: { roomId: string },
) {
// Remove from room
await this.leaveRoom(client.id, room);
// Notify others
this.emitToRoom(room, 'user:left', {
userId: client.id,
room,
});
}
@UseWsGuards(ChatAuthGuard)
@OnMessage('chat:{roomId}:message')
async handleMessage(
@Client() client: WsClientData,
@MessageData() data: ChatMessage,
@PatternParams() params: { roomId: string },
) {
// Validate client is in the room
if (!client.rooms.includes(`room:${params.roomId}`)) {
return {
event: 'error',
data: { message: 'Not in room' },
};
}
// Save message
const message = await this.chatService.saveMessage({
roomId: params.roomId,
userId: client.id,
text: data.text,
timestamp: Date.now(),
});
// Broadcast to room
this.emitToRoom(`room:${params.roomId}`, 'chat:message', message);
// Acknowledge sender
return {
event: 'chat:message:ack',
data: { messageId: message.id },
};
}
@OnMessage('typing:{roomId}')
handleTyping(
@Client() client: WsClientData,
@PatternParams() params: { roomId: string },
) {
// Broadcast typing indicator (except to sender)
this.emitToRoom(
`room:${params.roomId}`,
'typing',
{ userId: client.id },
[client.id],
);
}
// Helper method to get clients in a room
private async getClientsInRoom(roomName: string): Promise<string[]> {
const clients = await super.getClientsByRoom(roomName);
return clients.map(c => c.id);
}
}Step 2: Chat service
Business logic for messages and history.
// src/chat.service.ts
import { Service } from '@onebun/core';
interface Message {
id: string;
roomId: string;
userId: string;
text: string;
timestamp: number;
}
@Service()
export class ChatService {
private messages: Map<string, Message[]> = new Map();
private messageIdCounter = 0;
async saveMessage(data: Omit<Message, 'id'>): Promise<Message> {
const message: Message = {
id: `msg_${++this.messageIdCounter}`,
...data,
};
const roomMessages = this.messages.get(data.roomId) || [];
roomMessages.push(message);
this.messages.set(data.roomId, roomMessages);
return message;
}
async getMessageHistory(roomId: string, limit = 50): Promise<Message[]> {
const roomMessages = this.messages.get(roomId) || [];
return roomMessages.slice(-limit);
}
async clearRoom(roomId: string): Promise<void> {
this.messages.delete(roomId);
}
}Step 3: Auth guard
Optional guard to require authentication on message handlers.
// src/auth.guard.ts
import { createGuard, type WsExecutionContext } from '@onebun/core';
export const ChatAuthGuard = createGuard((context: WsExecutionContext) => {
const client = context.getClient();
// Check if client has authenticated
if (!client.auth?.authenticated) {
return false;
}
// Optional: Check for specific permissions
// return client.auth.permissions?.includes('chat:send') ?? false;
return true;
});Step 4: Register the gateway in the module
A WebSocket gateway is a controller. Add ChatGateway to the module's controllers array so the framework discovers it. Do not add it to providers.
// src/chat.module.ts
import { Module } from '@onebun/core';
import { ChatGateway } from './chat.gateway';
import { ChatService } from './chat.service';
@Module({
controllers: [ChatGateway], // Gateways are controllers — register here
providers: [ChatService],
})
export class ChatModule {}Step 5: Application entry and WebSocket config
Create the application and pass websocket in the options. You can optionally enable Socket.IO on a separate path.
// src/index.ts
import { OneBunApplication } from '@onebun/core';
import { ChatModule } from './chat.module';
const app = new OneBunApplication(ChatModule, {
port: 3000,
websocket: {
// Optional: enable Socket.IO on /socket.io for socket.io-client
// socketio: { enabled: true, path: '/socket.io' },
},
});
await app.start();
console.log('Chat server running on http://localhost:3000');
console.log('Native WebSocket: ws://localhost:3000/chat');
// If socketio.enabled: Socket.IO at ws://localhost:3000/socket.ioClient implementation
You can use: typed client (with definition), standalone client (no definition, same API), or Socket.IO (enable socketio in app config).
Option A: Typed client (native WebSocket, with definition)
Connect to the gateway path. Default protocol is 'native'.
// client-native.ts
import { createWsServiceDefinition, createWsClient } from '@onebun/core';
import { ChatModule } from './chat.module';
const definition = createWsServiceDefinition(ChatModule);
const client = createWsClient(definition, {
url: 'ws://localhost:3000/chat',
protocol: 'native',
auth: { token: 'user-jwt-token' },
reconnect: true,
reconnectInterval: 2000,
maxReconnectAttempts: 5,
});
// Connection lifecycle
client.on('connect', () => {
console.log('Connected to chat server');
});
client.on('disconnect', (reason) => {
console.log('Disconnected:', reason);
});
client.on('reconnect', (attempt) => {
console.log(`Reconnected after ${attempt} attempts`);
});
client.on('error', (error) => {
console.error('Connection error:', error);
});
// Connect
await client.connect();
// Subscribe to events
client.ChatGateway.on('welcome', (data) => {
console.log('Welcome message:', data.message);
});
client.ChatGateway.on('chat:message', (message) => {
console.log(`[${message.userId}]: ${message.text}`);
});
client.ChatGateway.on('user:joined', (data) => {
console.log(`User ${data.userId} joined ${data.room}`);
});
client.ChatGateway.on('user:left', (data) => {
console.log(`User ${data.userId} left ${data.room}`);
});
client.ChatGateway.on('typing', (data) => {
console.log(`${data.userId} is typing...`);
});
// Join a room
const roomInfo = await client.ChatGateway.emit('join', 'room:general');
console.log('Joined room with history:', roomInfo.history);
// Send a message
const ack = await client.ChatGateway.emit('chat:general:message', {
text: 'Hello everyone!',
});
console.log('Message sent, id:', ack.messageId);
// Send typing indicator
client.ChatGateway.send('typing:general', {});
// Leave room when done
client.ChatGateway.send('leave', 'room:general');
// Disconnect
client.disconnect();Option B: Standalone client (no definition)
Use when the frontend does not depend on the backend module (e.g. in a monorepo). Same message format and API as the typed client.
// client-standalone.ts
import { createNativeWsClient } from '@onebun/core';
const client = createNativeWsClient({
url: 'ws://localhost:3000/chat',
protocol: 'native',
auth: { token: 'user-jwt-token' },
reconnect: true,
});
client.on('connect', () => console.log('Connected to chat'));
client.on('welcome', (data) => console.log('Welcome:', data.message));
client.on('chat:message', (msg) => console.log(`[${msg.userId}]: ${msg.text}`));
client.on('user:joined', (data) => console.log(`User ${data.userId} joined`));
client.on('user:left', (data) => console.log(`User ${data.userId} left`));
await client.connect();
// Join room (server expects event 'join' with room name as data)
const roomInfo = await client.emit('join', 'room:general');
console.log('Joined room:', roomInfo);
await client.emit('chat:general:message', { text: 'Hello everyone!' });
client.send('typing:general', {});
client.send('leave', 'room:general');
client.disconnect();Option C: socket.io-client (Socket.IO protocol)
Enable Socket.IO in the application config (websocket.socketio.enabled: true), then connect to the server origin with path: '/socket.io'.
// client-socketio.ts
import { io } from 'socket.io-client';
const socket = io('http://localhost:3000', {
path: '/socket.io',
auth: { token: 'user-jwt-token' },
transports: ['websocket'],
});
socket.on('connect', () => {
console.log('Connected with id:', socket.id);
});
socket.on('welcome', (data) => {
console.log('Welcome:', data);
});
socket.on('chat:message', (message) => {
console.log('Message:', message);
});
// Join room
socket.emit('join', 'room:general', (response) => {
console.log('Room joined:', response);
});
// Send message
socket.emit('chat:general:message', { text: 'Hello!' }, (ack) => {
console.log('Message acknowledged:', ack);
});
socket.disconnect();Option D: Typed client with Socket.IO
If Socket.IO is enabled on the server, you can use the typed client with protocol: 'socketio' and the Socket.IO path:
const client = createWsClient(definition, {
url: 'ws://localhost:3000/socket.io',
protocol: 'socketio',
auth: { token: 'user-jwt-token' },
});
await client.connect();
// Same API: client.ChatGateway.emit(...), client.ChatGateway.on(...)Authentication
Token-based Authentication
Clients can authenticate by providing a token in the connection options:
const client = createWsClient(definition, {
url: 'ws://localhost:3000/chat',
auth: {
token: 'your-jwt-token',
},
});The token can be validated in a connect handler or guard:
@OnConnect()
async handleConnect(@Client() client: WsClientData) {
if (client.auth?.token) {
try {
const decoded = await verifyJwtToken(client.auth.token);
client.auth.authenticated = true;
client.auth.userId = decoded.userId;
client.auth.permissions = decoded.permissions;
} catch {
// Token invalid
client.auth.authenticated = false;
}
}
}Running the Example
- Start the server:
bun run src/index.ts- Connect clients using the typed client or socket.io-client.
Testing
// chat.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { OneBunApplication, createWsServiceDefinition, createWsClient } from '@onebun/core';
import { ChatModule } from './chat.module';
describe('Chat WebSocket', () => {
let app: OneBunApplication;
let client: ReturnType<typeof createWsClient>;
beforeAll(async () => {
app = new OneBunApplication(ChatModule, { port: 3001 });
await app.start();
const definition = createWsServiceDefinition(ChatModule);
client = createWsClient(definition, {
url: 'ws://localhost:3001/chat',
});
await client.connect();
});
afterAll(async () => {
client.disconnect();
await app.stop();
});
it('should receive welcome message on connect', (done) => {
client.ChatGateway.on('welcome', (data) => {
expect(data.message).toBe('Welcome to the chat!');
done();
});
});
it('should join room and receive history', async () => {
const response = await client.ChatGateway.emit('join', 'room:test');
expect(response.room).toBe('test');
expect(response.history).toBeArray();
});
it('should send and receive messages', async () => {
// Join room first
await client.ChatGateway.emit('join', 'room:test');
// Setup listener
const messagePromise = new Promise<any>((resolve) => {
client.ChatGateway.on('chat:message', resolve);
});
// Send message
const ack = await client.ChatGateway.emit('chat:test:message', { text: 'Hello' });
expect(ack.messageId).toBeDefined();
// Verify received
const received = await messagePromise;
expect(received.text).toBe('Hello');
});
});