WebSocket Wiring
Recipes are working wiring examples with the sharp edges annotated. This page and the
websocket-wiringrecipe 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) — ownsWsGatewaysPool,WsServertoken,WsConfig(allowedOriginsrequired —['*']is the explicit allow-all opt-in;pathdefault/ws,maxClients,idleTimeoutMs,fanout: 'local' | 'transport'— must match whether aWsFanoutTransportlikews-redisis wired, both mismatches fail boot). Decorators:@WsGateway,@OnConnect,@OnDisconnect,@OnMessage,@Client,@Data.WsFastifyModule(driver) — registers@fastify/websocket, walksWsGatewaysPoolinafterLoad, and bindsWsServerto its adapter. RequiresHttpFastifyModule(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.
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.