PlayMesh/Docs
HomeGitHubnpm

Middleware & Plugins

Intercept events with middleware and extend PlayMesh with reusable plugins.

Middleware

Middleware functions intercept every incoming client event before it reaches instance handlers. They follow the (context, next) pattern.

Signature

type Middleware = (
  context: MiddlewareContext,
  next: () => Promise<void>
) => void | Promise<void>;

interface MiddlewareContext {
  event: string;
  payload: unknown;
  session: Session;
  mesh: PlayMesh;
}

Logging

mesh.use(async (context, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  if (ms > 100) console.warn(`Slow event: ${context.event} - ${ms}ms`);
});

Rate limiting (built in)

PlayMesh ships a rateLimit middleware with per-session, per-event token buckets. '*' catches events without their own limit; over-limit events are rejected with a RATE_LIMITED error the client sees via onError.
import { rateLimit } from '@playmesh/server';

mesh.use(rateLimit({ 'player:move': 30, chat: 2, '*': 100 })); // events/second

// Escalate: warn twice, kick on the third violation within 10s
mesh.use(rateLimit({ '*': 50 }, { onExceed: 'kick', strikes: 3, strikeWindowMs: 10_000 }));
onExceed: 'warn' (default, error to the client), 'drop' (silent) or 'kick' (warn, then kick with code RATE_LIMITED after strikes violations - default 3 - without a quiet strikeWindowMs, default 10s). Every limited event is counted in mesh.metrics().errors.

Role-based access (built in)

requireRole protects events by role - a session needs at least one of the listed roles (granted by your authenticate hook or session.setRoles()). Events without a rule stay open. Denied events reach the client via onError with code ROLE_REQUIRED. Roles changed live apply to the next event.
import { requireRole } from '@playmesh/server';

mesh.use(requireRole({
  'kick-user': ['admin'],
  'mute-user': ['admin', 'moderator'],
}));

Custom authorization

const ADMIN_EVENTS = new Set(['ban-player', 'broadcast-message']);

mesh.use(async (context, next) => {
  if (ADMIN_EVENTS.has(context.event) && !context.session.hasRole('admin')) {
    context.session.send('unauthorized', { event: context.event });
    return;
  }
  await next();
});
✦
Don't write a catch-all middleware to handle lobby events (shop, wardrobe, game selection) from any room - register them with mesh.on(event, handler), which receives events from any session, even one in no instance.
⚠
Calling next()multiple times in the same middleware throws a "next() called multiple times" error.

Plugins

Plugins are objects with an install(mesh) method. They receive the PlayMesh instance and can register hooks, middleware, domains, and anything else.

Plugin interface

interface Plugin {
  name?: string;
  install(mesh: PlayMesh): void | Promise<void>;
}

Writing a plugin

import type { PlayMesh, Plugin } from '@playmesh/server';

class MatchmakingPlugin implements Plugin {
  readonly name = 'matchmaking';
  private queue: string[] = [];

  constructor(private readonly minPlayers: number) {}

  async install(mesh: PlayMesh) {
    const domain = mesh.createDomain('ranked');
    const lobby = domain.createInstance('lobby');

    mesh.onSessionCreate(async session => {
      this.queue.push(session.id);
      if (this.queue.length >= this.minPlayers) {
        const players = this.queue.splice(0, this.minPlayers);
        const match = domain.createInstance(`match-${Date.now()}`);
        for (const id of players) {
          const s = mesh.sessions.find(s => s.id === id);
          if (s) {
            await s.leave(lobby);
            await s.join(match);
          }
        }
      }
    });

    mesh.onDisconnect(session => {
      this.queue = this.queue.filter(id => id !== session.id);
    });
  }
}

Using a plugin

const mesh = new PlayMesh({ port: 4000 });
mesh.use(new MatchmakingPlugin(2));
await mesh.start();
✦
Plugins are installed before bootstrap hooks run, so a plugin can safely register its own bootstrap hooks.