Concepts

Component Model

The five component types — services, controllers, managers, watchers, and middlewares — and when to use each.

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):

ParameterTypeDescription
socketanyThe Socket.IO socket for this connection
dataanyPayload sent by the client
callbackFunction | undefinedOptional 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.

Always register managers before any component that uses them. 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],
});
Copyright © 2026