Skip to content

WebSocket Wiring

Recipes are working wiring examples with the sharp edges annotated. This page and the websocket-wiring recipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.

Two packages, same split as HTTP:

  • WsModule (contract) — owns WsGatewaysPool, WsServer token, WsConfig (allowedOrigins required['*'] is the explicit allow-all opt-in; path default /ws, maxClients, idleTimeoutMs, fanout: 'local' | 'transport' — must match whether a WsFanoutTransport like ws-redis is wired, both mismatches fail boot). Decorators: @WsGateway, @OnConnect, @OnDisconnect, @OnMessage, @Client, @Data.
  • WsFastifyModule (driver) — registers @fastify/websocket, walks WsGatewaysPool in afterLoad, and binds WsServer to its adapter. Requires HttpFastifyModule (it reaches into the same Fastify instance).

A gateway is registered three coordinated pieces: the @WsGateway(path) decorator, a providers entry, and a WsGatewaysPool contribution. The adapter does if (!container.isBound(gatewayMeta.target)) continue; (see packages/ws-fastify/src/ws-adapter.ts) — a @WsGateway class that isn't bound in DI is silently skipped, no route registered, no error. Same trap shape as @Controller without HttpControllersPool.

The full URL is WsConfig.path + gateway.path. @WsGateway('/chat') with default WsConfig.path = '/ws' listens at /ws/chat. Handler return values from @OnMessage are auto-JSON.stringify'd and sent to the originating client (return undefined to send nothing); use WsServer.broadcast(type, data) to fan out to every connected client across all gateways.

typescript
import { Inject } from '@modularityjs/di';
import { HttpModule } from '@modularityjs/http';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import { Module } from '@modularityjs/modularity';
import type { WsClient } from '@modularityjs/ws';
import {
  Client,
  Data,
  OnConnect,
  OnDisconnect,
  OnMessage,
  WsGateway,
  WsGatewaysPool,
  WsModule,
  WsServer,
} from '@modularityjs/ws';
import { WsFastifyModule } from '@modularityjs/ws-fastify';

@WsGateway('/chat') // listens at WsConfig.path + '/chat' (default '/ws/chat')
class ChatGateway {
  // WsServer is bound by WsFastifyModule in afterLoad — safe to inject;
  // do not call from your own afterLoad (ordering not guaranteed there).
  constructor(@Inject(WsServer) private readonly ws: WsServer) {}

  @OnConnect()
  handleConnect(@Client() client: WsClient) {
    this.ws.broadcast('system', { text: `${client.id} joined` });
  }

  @OnDisconnect()
  handleDisconnect(@Client() client: WsClient) {
    this.ws.broadcast('system', { text: `${client.id} left` });
  }

  @OnMessage('ping') // matches inbound { "type": "ping", "data": ... }
  handlePing() {
    return { type: 'pong', data: { time: Date.now() } }; // sent back to sender
  }

  @OnMessage('chat')
  handleChat(@Client() client: WsClient, @Data('text') text: string) {
    this.ws.broadcast('chat', { from: client.id, text });
  }
}

@Module({
  name: 'chat',
  imports: [WsModule, HttpModule], // pool owner + the driver's HTTP dep
  providers: [ChatGateway],
  pools: [{ pool: WsGatewaysPool, key: 'chat', useClass: ChatGateway }], // class pool
})
class ChatModule {}

// modules: [
//   ModularityModule,
//   HttpModule.forRoot({ port: 3000 }),
//   HttpFastifyModule,                                  // required — ws-fastify reuses the Fastify instance
//   WsModule.forRoot({ allowedOrigins: ['https://app.example.com'] }), // required — ['*'] is the explicit allow-all opt-in
//   WsFastifyModule,
//   ChatModule,
// ]

Inbound frames must be JSON of the shape { "type": string, "data": unknown }. Non-JSON frames are dropped (debug-logged via NODE_DEBUG=modularityjs:ws-fastify); unknown type values are dropped silently. @Data() returns the whole data payload; @Data('text') picks one field. Handler exceptions are caught and surface as { type: 'error', data: { messageType } } to the originating client — they never crash the connection. Lifecycle handler (@OnConnect/@OnDisconnect) errors emit a process.emitWarning and are swallowed; don't put critical setup there without your own try/catch.