Chat Application
Chat Application
The chat application at examples/chat-app/ demonstrates a real-world IOServer backend with all five component types working together.
What it does
- Username login (no password, unique per session)
- Default
generalroom on join - Room creation and switching
- Message history per room
- Typing indicators
- Real-time user lists
- Statistics API
- Responsive web UI served from the controller (HTML inline)
Structure
examples/chat-app/
├── app.ts # Entry point
├── services/
│ └── ChatService.ts # WebSocket events
├── controllers/
│ ├── ChatController.ts # Serves HTML UI
│ └── ApiController.ts # REST API
├── managers/
│ └── StatsManager.ts # Shared statistics
├── watchers/
│ └── ChatWatcher.ts # Background tasks
└── routes/
├── chat.json # ChatController routes
└── api.json # ApiController routes
Entry point (app.ts)
import { IOServer } from "../../src";
import { ChatService } from "./services/ChatService";
import { ChatController } from "./controllers/ChatController";
import { ApiController } from "./controllers/ApiController";
import { StatsManager } from "./managers/StatsManager";
import { ChatWatcher } from "./watchers/ChatWatcher";
const server = new IOServer({
host: "localhost",
port: 3000,
verbose: "DEBUG",
cors: { origin: ["http://localhost:3000"], methods: ["GET", "POST"] },
mode: ["websocket", "polling"],
routes: "./examples/chat-app/routes",
});
// 1. Managers first — available in appHandle before anything else
server.addManager({ name: "statsManager", manager: StatsManager });
// 2. Watchers — watch() called at start()
server.addWatcher({ name: "chatWatcher", watcher: ChatWatcher });
// 3. Services — WebSocket namespace /chat
server.addService({ name: "chat", service: ChatService });
// 4. Controllers — HTTP routes registered now
server.addController({ name: "chat", controller: ChatController, prefix: "" });
server.addController({ name: "api", controller: ApiController });
await server.start();
Why managers first? StatsManager is accessed by ChatService, ChatWatcher, and ApiController. It must be on appHandle before those components are instantiated.
Routes
routes/chat.json — ChatController
[
{ "method": "GET", "url": "/", "handler": "getIndex" },
{ "method": "GET", "url": "/health", "handler": "getHealth" }
]
With prefix: "":
GET /→getIndex(serves full HTML chat UI)GET /health→getHealth
routes/api.json — ApiController
[
{ "method": "GET", "url": "/status", "handler": "getStatus" },
{ "method": "GET", "url": "/stats", "handler": "getStats" }
]
With default prefix (/api):
GET /api/status→getStatusGET /api/stats→getStats(returns live stats fromStatsManager)
StatsManager
Singleton tracking usage numbers. The start() hook is used to log its initialization:
export class StatsManager extends BaseManager {
private stats = { totalUsers: 0, activeRooms: 0, messagesCount: 0, peakConcurrentUsers: 0 };
private startTime = new Date();
incrementUsers(): void { /* ... updates totalUsers and peak */ }
decrementUsers(): void { /* ... */ }
incrementMessages(): void { /* ... */ }
setActiveRooms(count: number): void { /* ... */ }
getStats(): typeof this.stats & { uptime: string } {
const diffMs = Date.now() - this.startTime.getTime();
// format uptime ...
return { ...this.stats, uptime };
}
}
ApiController.getStats() calls this.appHandle.statsManager.getStats() to expose this over HTTP.
ChatService highlights
All user/room state lives inside the service instance (in-memory Maps):
private users: Map<string, User> = new Map();
private rooms: Map<string, Set<string>> = new Map();
private messageHistory: Map<string, Message[]> = new Map();
private usernames: Set<string> = new Set();
login event
- Validates username (non-empty, unique)
- Creates a
Userrecord keyed bysocket.id - Joins the
generalSocket.IO room - Broadcasts a system message to the room
- Calls back with current room users and recent messages
async login(socket: any, data: { username: string }, callback?: Function): Promise<void> {
// validate → create user → socket.join("general") → broadcast → callback
}
send_message event
- Looks up user by
socket.id - Creates a message object
- Broadcasts to room via
appHandle.send({ namespace: "chat", room, event: "new_message", data: message }) - Increments message count on
statsManager
join_room event
- Leaves current Socket.IO room (
socket.leave) - Creates room if new
- Joins new Socket.IO room (
socket.join) - Sends system messages to both old and new room
disconnect event
Socket.IO fires disconnect automatically. Since disconnect does not start with _, IOServer registers it as a socket event handler:
async disconnect(socket: any, data: any): Promise<void> {
const user = this.users.get(socket.id);
if (!user) return;
// Remove from room and user maps
this.rooms.get(user.room)?.delete(socket.id);
this.users.delete(socket.id);
this.usernames.delete(user.username.toLowerCase());
// Notify room
socket.to(user.room).emit("user_left", { username: user.username });
this.appHandle.statsManager?.decrementUsers();
this.appHandle.log(6, `${user.username} disconnected`);
}
ChatWatcher
Runs three periodic checks in the background:
async watch(): Promise<void> {
// Cleanup every 30 minutes
this.intervals.push(setInterval(() => this.cleanupOldMessages(), 30 * 60_000));
// Log stats every 5 minutes
this.intervals.push(setInterval(() => this.logStats(), 5 * 60_000));
// Memory health every minute
this.intervals.push(setInterval(() => this.monitorHealth(), 60_000));
}
logStats() reads from appHandle.statsManager:
private logStats(): void {
const stats = this.appHandle.statsManager?.getStats();
if (stats) this.appHandle.log(6, `Users: ${stats.totalUsers}, Messages: ${stats.messagesCount}`);
}
Running the example
# From the IOServer root
pnpm run dev:chat
# or
npx ts-node examples/chat-app/app.ts
Open http://localhost:3000 to see the chat UI, or:
curl http://localhost:3000/api/stats