Skip to content

Centrifugo

Centrifugo is a standalone real-time server. Clients connect to it, not to your Node process; your KickJS app authenticates those connections and publishes through Centrifugo's server API. @forinda/kickjs-ws/centrifugo covers the KickJS side.

When to choose it

WsAdapter + Redis brokerCentrifugo
Who holds the socketsYour Node instancesCentrifugo (Go)
Client → server messages@OnMessage handlersYour HTTP routes, or Centrifugo's RPC/publish proxies
Extra infrastructureRedisCentrifugo (plus its own Redis/NATS when you run several nodes)
History, presence, recovery on reconnectBuild it yourselfBuilt in

Start with WsAdapter — it keeps handlers in your code and scales out with a broker. Move to Centrifugo when connection count or delivery features outgrow what you want to run in Node.

What does not carry over

Centrifugo owns the connections, so nothing that acts on a socket applies: @WsController, @OnConnect, @OnMessage, @OnDisconnect, WsContext, rooms (ctx.join, ctx.to) and WS_ROOM_MANAGER. Channels replace rooms; clients subscribe to them, or you subscribe users server-side with subscribe(user, channel).

WS_USER_BROADCASTER does carry over — see Publishing from services.

Centrifugo config

Keys as Centrifugo v6 reads them (config.json, or CENTRIFUGO_-prefixed environment variables such as CENTRIFUGO_HTTP_API_KEY):

json
{
  "http_api": { "key": "<api key>" },
  "client": {
    "allowed_origins": ["https://app.example.com"],
    "token": { "hmac_secret_key": "<hmac secret>" },
    "subscribe_to_user_personal_channel": { "enabled": true },
    "proxy": {
      "connect": {
        "enabled": true,
        "endpoint": "http://api:3000/centrifugo/connect",
        "http_headers": ["Cookie"]
      }
    }
  },
  "channel": {
    "without_namespace": { "allow_subscribe_for_client": true },
    "namespaces": [{ "name": "orders" }]
  }
}
  • http_api.key — what CentrifugoAdapter sends as X-API-Key.
  • client.token.hmac_secret_key — what connectionToken signs with. Needed only for the token flow.
  • client.proxy.connect — needed only for the proxy flow. Centrifugo forwards only the headers listed in http_headers; without Cookie your resolver sees no session.
  • client.subscribe_to_user_personal_channel — subscribes each user to #<user> on connect, which is where WS_USER_BROADCASTER publishes.
  • channel.without_namespace.allow_subscribe_for_client — lets any connected client subscribe to a channel without a : namespace, such as news, with no further authorization. Treat those channels as public to every signed-in user and never publish restricted data on them. Personal #<user> channels are unaffected.
  • channel.namespaces — a channel such as orders:42 needs its namespace declared, or Centrifugo answers 102: unknown channel. orders does not allow client subscriptions, so only users the server subscribes receive it — see Publishing from services.

Keep service traffic on a trusted network

http://api:3000 and http://centrifugo:8000 stand for addresses on a private service network. The connect proxy forwards the user's Cookie header, and the adapter sends the API key on every call. When the two services talk across a network you do not control, use HTTPS or another authenticated, encrypted link.

Register the adapter

ts
import { bootstrap, getEnv } from '@forinda/kickjs'
import { CentrifugoAdapter } from '@forinda/kickjs-ws/centrifugo'

bootstrap({
  modules,
  adapters: [
    CentrifugoAdapter({
      // Declared in your env schema (src/config/index.ts)
      url: getEnv('CENTRIFUGO_URL'), // e.g. http://centrifugo:8000 on the service network
      apiKey: getEnv('CENTRIFUGO_API_KEY'),
    }),
  ],
})

If you set client.subscribe_to_user_personal_channel.personal_channel_namespace, pass the same value as personalChannelNamespace so the broadcaster publishes to <namespace>:#<user>.

Authenticating connections

Pick one of two flows.

Connection token

Your app signs a short-lived JWT; the client passes it to Centrifugo.

ts
import { Controller, Get, getEnv, type RequestContext } from '@forinda/kickjs'
import { connectionToken } from '@forinda/kickjs-ws/centrifugo'

@Controller()
export class RealtimeController {
  @Get('/token')
  async token(ctx: RequestContext) {
    const user = await sessions.fromRequest(ctx.req) // your session lookup
    if (!user) return ctx.json({ error: 'unauthorized' }, 401)

    ctx.json({
      token: connectionToken({
        secret: getEnv('CENTRIFUGO_HMAC_SECRET'),
        sub: user.id,
        expiresInSeconds: 15 * 60,
        info: { name: user.name }, // visible to others in presence
      }),
    })
  }
}

connectionToken signs HS256 with node:crypto and sets the sub, exp, info and channels claims Centrifugo reads.

Connect proxy

Centrifugo calls your app on every connection and uses the reply. Reuse the resolveUser you would give WsAdapter:

ts
import { Controller, Post, type RequestContext } from '@forinda/kickjs'
import { centrifugoConnect } from '@forinda/kickjs-ws/centrifugo'

// Mount under /centrifugo so the path matches client.proxy.connect.endpoint
@Controller()
export class CentrifugoProxyController {
  @Post('/connect')
  async connect(ctx: RequestContext) {
    ctx.json(await centrifugoConnect(ctx.req, resolveUser))
  }
}

// The same shape as WsAuthConfig.resolveUser
async function resolveUser(req: { headers: Record<string, string | string[] | undefined> }) {
  const sid = parseCookie(req.headers.cookie).sid
  return sid ? await sessions.verify(sid) : null
}
resolveUserReplyClient sees
returns { id }{ result: { user: id } }connected as that user
returns null (or no id){ disconnect: { code: 4401, reason: 'unauthorized' } }socket closed with 4401
throws{ error: { code: 100, message: 'internal server error' } }connect error (Centrifugo's internal error code)

The route must be reachable from Centrifugo, and exempt from csrf() if you use it — Centrifugo sends no CSRF token.

Publishing from services

CentrifugoAdapter registers two tokens:

ts
import { Inject, Service } from '@forinda/kickjs'
import { WS_USER_BROADCASTER, type WsUserBroadcaster } from '@forinda/kickjs-ws'
import { CENTRIFUGO, type CentrifugoClient } from '@forinda/kickjs-ws/centrifugo'

@Service()
export class OrderEvents {
  constructor(
    @Inject(CENTRIFUGO) private readonly centrifugo: CentrifugoClient,
    @Inject(WS_USER_BROADCASTER) private readonly users: WsUserBroadcaster,
  ) {}

  async placed(order: { id: string; userId: string }) {
    // Server-side subscription: only the order's owner receives orders:<id>.
    // It applies to the user's current connections — list the channel in the
    // connection token's `channels` to have it on every reconnect.
    await this.centrifugo.subscribe(order.userId, `orders:${order.id}`)
  }

  async shipped(order: { id: string; userId: string }) {
    await this.centrifugo.publish(`orders:${order.id}`, { status: 'shipped' })
    this.users.toUser(order.userId).send('order:shipped', { id: order.id })
  }
}
  • CENTRIFUGOpublish(channel, data), broadcast(channels, data), subscribe(user, channel), disconnect(user). Each returns a promise and throws CentrifugoApiError (with method and code) when Centrifugo rejects the call.
  • WS_USER_BROADCASTER — the same interface WsAdapter registers. It publishes { event, data } to the user's personal channel. Code written against WsAdapter works unchanged; like WsAdapter's broker relay, a failed publish is logged rather than thrown.

Client

Use Centrifugo's client SDK:

pnpm add centrifuge
ts
import { Centrifuge } from 'centrifuge'

const centrifuge = new Centrifuge('wss://realtime.example.com/connection/websocket', {
  // Token flow; omit for the connect proxy flow
  getToken: async () => (await fetch('/token').then((r) => r.json())).token,
})

// Server-side subscriptions: the personal channel, and orders:<id> the server subscribed this user to
centrifuge.on('publication', (ctx) => console.log(ctx.channel, ctx.data))

// Client-side subscription to a public channel (no namespace)
const news = centrifuge.newSubscription('news')
news.on('publication', (ctx) => console.log(ctx.data))
news.subscribe()

centrifuge.connect()

Released under the MIT License. Built with TypeScript — runs on Express, Fastify, or h3.