Config
Installation
There are two ways to install the psychic-websockets package. The first is by selecting yes when prompted during the initial psychic app provisioning stage. If you select yes, the package will be automatically installed, and your app bootstrapped to use websockets automatically.
However, if this is not the case for you and you are looking to install websockets after the fact, you can follow these steps:
- Install the package.
pnpm add @rvoh/psychic-websockets
- Add the missing configuration file to
src/conf/initializers/websockets.ts:
import AppEnv from '@conf/AppEnv.js'
import allowedCorsOrigins from '@conf/system/allowedCorsOrigins.js'
import resolveWebsocketUser from '@conf/system/resolveWebsocketUser.js'
import { PsychicApp } from '@rvoh/psychic'
import { allowRequestForOrigins, PsychicAppWebsockets, Ws } from '@rvoh/psychic-websockets'
import { Redis } from 'ioredis'
export default (psy: PsychicApp) => {
psy.plugin(async () => {
await PsychicAppWebsockets.init(psy, initializeWebsockets)
})
}
function initializeWebsockets(wsApp: PsychicAppWebsockets) {
// The websockets transport adapter is selected per environment:
// - test: in-process adapter (the default) — no Redis needed.
// - development
// - production: Redis adapter (the default) — distributes the socket
// registry and broadcasts across a clustered websocket fleet.
if (!AppEnv.isTest) {
wsApp.set(
'connection',
AppEnv.isProduction
? new Redis({
host: AppEnv.string('WS_REDIS_HOST'),
port: AppEnv.integer('WS_REDIS_PORT', { optional: true }) || 6379,
username: AppEnv.string('WS_REDIS_USERNAME'),
password: AppEnv.string('WS_REDIS_PASSWORD'),
tls: {},
maxRetriesPerRequest: 3,
commandTimeout: 10000,
})
: new Redis({
host: AppEnv.string('WS_REDIS_HOST', { optional: true }) || 'localhost',
port: AppEnv.integer('WS_REDIS_PORT', { optional: true }) || 6379,
username: AppEnv.string('WS_REDIS_USERNAME', { optional: true }),
password: AppEnv.string('WS_REDIS_PASSWORD', { optional: true }),
// tls: {},
maxRetriesPerRequest: 3,
commandTimeout: 10000,
}),
)
}
wsApp.set('socketio', {
allowRequest: allowRequestForOrigins(allowedCorsOrigins()),
})
wsApp.on('ws:start', io => {
io.of('/').on('connection', async socket => {
const user = await resolveWebsocketUser(socket)
if (!user) {
socket.disconnect(true)
return
}
await Ws.register(socket, user.id)
})
})
wsApp.on('ws:connect', () => {
// do something upon websocket connection being established
})
}
- No changes to
initializePsychicApp.tsare needed. The initializer file registers itself as a plugin viapsy.plugin(...), soPsychicApp.init()picks it up automatically.
PsychicAppWebsockets.init() runs in all processes by default. Any process — websocket server, web server, or worker — may call Ws.emit(), and skipping init in any of them causes a runtime cachePsychicAppWebsockets error that is easy to misdiagnose. Each Node process has its own module cache and does not share the websocket app instance with other processes.
To restrict which roles can push messages, add a guard in conf/initializers/websockets.ts:
if (!['websockets', 'web', 'worker'].includes(AppEnv.serviceRole) && !AppEnv.isTest) return
- We recommend you include a singleton within your application to simplify your websockets integration:
import { Ws } from '@rvoh/psychic-websockets'
export const WS_ROUTES = ['/ops/connection-success'] as const
const ws = new Ws(WS_ROUTES)
export default ws
Configuration
The configuration for the psychic-websockets package is driven by the conf/initializers/websockets.ts file. This file contains both basic bootstrapping information for redis, as well as hooks to tap into to initialize socket.io and establish websocket listeners for your backend application.
Adapter
psychic-websockets selects a transport adapter per environment automatically:
- Test —
InProcessWebsocketsAdapter(the default). No Redis connection is opened. Unit specs do zero Redis I/O; broadcasts are recorded in-process so your test helpers can assert on them. - Development / Production —
RedisWebsocketsAdapter(the default). Distributes the socket registry and fan-out broadcasts across a clustered websocket fleet.
To override the automatic selection, call wsApp.set('adapter', ...) anywhere in initializeWebsockets:
// Force the Redis adapter even in test (unusual):
wsApp.set('adapter', 'redis')
// Force the in-process adapter in all environments:
wsApp.set('adapter', 'in_process')
// Supply a fully custom adapter instance:
wsApp.set('adapter', new MyCustomAdapter())
Redis
Redis — used to distribute broadcasts across a websocket fleet — is configured with wsApp.set('connection', ...). Wrap it in if (!AppEnv.isTest) so your test suite needs no Redis:
if (!AppEnv.isTest) {
wsApp.set(
'connection',
AppEnv.isProduction
? new Redis({
host: AppEnv.string('WS_REDIS_HOST'),
port: AppEnv.integer('WS_REDIS_PORT', { optional: true }) || 6379,
username: AppEnv.string('WS_REDIS_USERNAME'),
password: AppEnv.string('WS_REDIS_PASSWORD'),
tls: {},
maxRetriesPerRequest: 3,
commandTimeout: 10000,
})
: new Redis({
host: AppEnv.string('WS_REDIS_HOST', { optional: true }) || 'localhost',
port: AppEnv.integer('WS_REDIS_PORT', { optional: true }) || 6379,
username: AppEnv.string('WS_REDIS_USERNAME', { optional: true }),
password: AppEnv.string('WS_REDIS_PASSWORD', { optional: true }),
// tls: {},
maxRetriesPerRequest: 3,
commandTimeout: 10000,
}),
)
}
maxRetriesPerRequest: nullUnlike a BullMQ worker connection (which must use maxRetriesPerRequest: null for its blocking BLPOP/BRPOPLPUSH commands), the socket.io redis-adapter issues no blocking commands. Setting null here makes a broadcast or socket-registry lookup hang indefinitely when Redis is unreachable — including a Ws.emit() from a worker, which stalls that job. Bound maxRetriesPerRequest and set a commandTimeout so the connection fails fast instead. The adapter needs no special connection options — see the socket.io Redis adapter docs; for the contrasting worker requirement see BullMQ connections.
Connection limits
Two connection-limit options are available via wsApp.set(...). Both already default to the values shown below — set them only if you need to override:
// Cap on simultaneous socket registrations per user. When a user
// registers a new socket past this limit, their oldest socket is evicted.
// This bounds per-user resource use — a client reconnecting in a loop
// can't accumulate unbounded registry entries.
wsApp.set('maxConnectionsPerUser', 3)
// TTL on the per-user socket-id registry key in Redis.
// This is a garbage-collection backstop for ungraceful disconnects —
// it is NOT the live socket's lifetime (socket.io's ping settings govern that).
// Keep it comfortably above your longest expected connection. If the TTL
// expires while a socket is still connected, emits to that user will
// silently stop until the socket reconnects and re-registers.
// Accepts: { seconds?, minutes?, hours?, days? }
wsApp.set('maxConnectionTtl', { days: 1 })
Hooks
In addition to configuration, psychic-websockets also exposes hooks to tap into during various lifecycle events exposed by the websockets app.
ws:start
The ws:start event is called whenever the psychic server is started. This enables you to establish socket bindings. Under the hood we are using socket.io to power websocket bindings, which means you can visit their documentation to understand more about setting up a websockets app within your application.
export default (wsApp: PsychicAppWebsockets) => {
// ...
wsApp.on('ws:start', io => {
// use socket.io to establish namespaced channels
// for your app to communicate on
io.of('/').on('connection', async socket => {
// this is an example of how you might be handling
// socket.io authentication. It would require extra
// setup on both your frontend and backend clients,
// but would enable you to emit to any user from anywhere
// within the application.
const token = socket.handshake.auth.token as string
const userId = Encrypt.decrypt<string>(token, {
algorithm: 'aes-256-gcm',
key: AppEnv.string('APP_ENCRYPTION_KEY'),
})!
const user = await User.find(userId)
if (user) {
// this automatically fires the /ops/connection-success message
await Ws.register(socket, user.id)
}
// establish socket routes using socket.on
})
})
}
Client Transport
Always set transports: ['websocket'] on the Socket.IO client. Socket.IO defaults to long-polling first for historical reasons — that fallback is unnecessary today since WebSocket is universally supported by modern browsers and mobile apps. Skipping the polling phase means faster connection establishment and eliminates a class of subtle failures where polling requests interfere with the websocket server's HTTP handler.
import { io } from 'socket.io-client'
const socket = io(websocketHost, {
transports: ['websocket'],
auth: { token },
})