Component Model
Component Model
IOServer enforces a five-role model. Each role has a base class, a registration method, and a specific lifecycle. Choosing the right role for each piece of logic is the key to a clean IOServer codebase.
Decision guide
Does it handle WebSocket events? → Service
Does it handle HTTP requests? → Controller
Does it need to be shared? → Manager
Does it run in the background? → Watcher
Does it intercept requests/sockets? → Middleware
Services
Transport: WebSocket (Socket.IO)
Lifecycle: Instantiated at addService(), connected at start()
Base class: BaseService
Every public method whose name does not start with _ automatically becomes a Socket.IO event handler on the service's namespace. No registration boilerplate.
class NotificationService extends BaseService {
// Automatically registered as socket.on("subscribe", ...)
async subscribe(socket: any, data: { topic: string }, callback?: Function): Promise<void> {
await socket.join(data.topic);
if (callback) callback({ status: "success", topic: data.topic });
}
// Automatically registered as socket.on("unsubscribe", ...)
async unsubscribe(socket: any, data: { topic: string }, callback?: Function): Promise<void> {
await socket.leave(data.topic);
if (callback) callback({ status: "success" });
}
// NOT registered — underscore prefix marks it as private
private _validateTopic(topic: string): boolean {
return typeof topic === "string" && topic.length > 0;
}
}
Socket method signature (always three parameters):
| Parameter | Type | Description |
|---|---|---|
socket | any | The Socket.IO socket for this connection |
data | any | Payload sent by the client |
callback | Function | undefined | Optional acknowledgement callback |
constructor is automatically excluded from event registration. Methods inherited from Object.prototype are iterated but harmless (Socket.IO ignores events with no listener) — prefix your internal helpers with _ to be explicit.Controllers
Transport: HTTP (Fastify)
Lifecycle: Instantiated at addController(), routes registered immediately
Base class: BaseController
Controller methods are not auto-discovered. They must be referenced by name in a JSON route file. This explicit mapping is intentional — it keeps routing configuration visible and separate from handler logic.
class UserController extends BaseController {
async getUser(request: any, reply: any): Promise<void> {
const { id } = request.params as { id: string };
const user = await this.appHandle.userManager.findById(id);
if (!user) throw new IOServerError("User not found", 404);
reply.send(user);
}
async createUser(request: any, reply: any): Promise<void> {
const body = request.body as { name: string; email: string };
const user = await this.appHandle.userManager.create(body);
reply.code(201).send(user);
}
}
Corresponding route file (routes/user.json):
[
{ "method": "GET", "url": "/:id", "handler": "getUser" },
{ "method": "POST", "url": "/", "handler": "createUser" }
]
With addController({ name: "user", controller: UserController }), these become GET /user/:id and POST /user/.
Managers
Transport: None (shared state)
Lifecycle: Instantiated synchronously at addManager(), available in appHandle immediately
Base class: BaseManager
Managers are the dependency injection mechanism in IOServer. They are singletons — one instance created at registration, accessible to every Component via this.appHandle.managerName.
class CacheManager extends BaseManager {
private store: Map<string, { value: any; expires: number }> = new Map();
// Optional: called automatically right after instantiation (non-blocking)
async start(): Promise<void> {
this.appHandle.log(6, "CacheManager started");
// Connect to Redis, warm cache, etc.
}
set(key: string, value: any, ttlMs: number = 60_000): void {
this.store.set(key, { value, expires: Date.now() + ttlMs });
}
get(key: string): any | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expires) {
this.store.delete(key);
return undefined;
}
return entry.value;
}
}
If a manager defines an async start() method, IOServer calls it automatically (and non-blockingly) right after instantiation. Errors from start() are logged but do not abort the server.
addManager is the only registration call that takes effect immediately.Watchers
Transport: None (background)
Lifecycle: Instantiated at addWatcher(), watch() called at start()
Abstract methods: watch(): Promise<void> and stop(): void
Watchers run background loops alongside the server. Both abstract methods must be implemented.
class CleanupWatcher extends BaseWatcher {
private intervalId: ReturnType<typeof setInterval> | null = null;
async watch(): Promise<void> {
this.appHandle.log(6, "CleanupWatcher started");
this.intervalId = setInterval(() => this.cleanup(), 15 * 60 * 1000);
}
stop(): void {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.appHandle.log(6, "CleanupWatcher stopped");
}
private cleanup(): void {
this.appHandle.log(6, "Running scheduled cleanup");
// e.g. purge expired sessions from a manager
}
}
watch() calls run in parallel (via Promise.all) and do not block start(). Errors inside watch() are caught and logged per-watcher.
stop() is called during server.stop() before closing the HTTP server.
Middlewares
Transport: HTTP and/or WebSocket
Lifecycle: Instantiated fresh per route/namespace at registration time
Abstract method: handle(appHandle): (req, reply, done) => void
Middlewares guard routes or namespaces. The handle method receives the appHandle (so it can access managers) and returns the actual middleware function.
HTTP middleware (Fastify preValidation hook)
class RateLimitMiddleware extends BaseMiddleware {
handle(appHandle: AppHandle) {
return (req: any, reply: any, done: any) => {
const ip = req.ip;
const allowed = appHandle.rateLimiter.check(ip);
if (!allowed) {
return reply.code(429).send({
statusCode: 429,
error: "Too Many Requests",
message: "Rate limit exceeded",
});
}
done();
};
}
}
WebSocket middleware (Socket.IO namespace use hook)
class SocketAuthMiddleware extends BaseMiddleware {
handle(appHandle: AppHandle) {
return (socket: any, next: any) => {
const token = socket.handshake.auth?.token;
if (!token) return next(new Error("Authentication required"));
try {
socket.user = appHandle.authManager.verify(token);
next();
} catch {
next(new Error("Invalid token"));
}
};
}
}
Apply middlewares at registration:
// HTTP controller
server.addController({
name: "api",
controller: ApiController,
middlewares: [RateLimitMiddleware],
});
// WebSocket service
server.addService({
name: "chat",
service: ChatService,
middlewares: [SocketAuthMiddleware],
});