Skip to main content

Registering

Registering users with the Ws class is imperative if you intend to later emit things to them. Without making this connection, Psychic will not be able to know which socket thread corresponds with which user. This means that when your front end websocket client is connecting, it needs to provide some kind of auth token to indicate which user it corresponds to. Usually, this auth token will be an encrypted payload containing an identifier which points to your user. Here is an example of how this flow might look in an application using a bare-bones authentication pattern:

// app/controllers/UsersController.ts
...

public async login() {
const user = await User.findBy({ email: this.param('email') })
if (!user || !(await user.checkPassword(this.param('password')))) this.notFound()

this.startSession(user!)

// this token is used for authenticating via websockets
const token = Encrypt.encrypt({ userId: user!.id }, {
algorithm: 'aes-256-gcm',
key: AppEnv.string('APP_ENCRYPTION_KEY'),
})

this.ok(token)
}

The token returned by this endpoint can then be used on the front end client to attach to the handshake of the socket.io-client library when reaching out to make a connection.

// somewhere in your front end code:
import { io } from 'socket.io-client'

try {
// this token will be the token that was rendered as the result
// of the `login` method above:
const token = await AuthAPI.login(email, password)

const socket = io(`http://localhost:7777`, {
auth: {
token,
},
withCredentials: true,
transports: ['websocket'],
})

// emitted by the ws:connect hook once this socket is signed in
// and registered (see below)
socket.on('/ops/connection-success', message => {
console.log(message)
})
} catch {
// handle auth failure
}

Finally, in src/conf/initializers/websockets.ts, sign each socket in and register it with Ws from a ws:connect hook:

// src/conf/initializers/websockets.ts
function initializeWebsockets(wsApp: PsychicAppWebsockets) {
wsApp.on('ws:connect', async socket => {
const user = await resolveWebsocketUser(socket)
if (!user) {
socket.disconnect(true)
return
}

await Ws.register(socket, user.id) // Redis key 'user:{id}:socket_ids'

const ws = new Ws(['/ops/connection-success'] as const)
await ws.emit(user.id, '/ops/connection-success', { message: 'Connected' })
})
}

A throw inside a ws:connect hook is contained to that socket: the framework logs it, disconnects the socket, and reports it to ws:error (see Config).

resolveWebsocketUser is the auth helper create-psychic scaffolds at conf/system/resolveWebsocketUser.ts — decrypting the token from the login endpoint above with Encrypt.decrypt, and returning null for a token it can't decrypt, is what belongs inside its body. The scaffolded version throws outside the test environment, so a newly-generated app rejects every websocket connection until it is replaced with your production authentication. See Config.

Once this is done, you are able to emit from anywhere in your backend application to this user using the #emit method.

ws = new Ws(['/users/ping', 'users/alert', 'users/info'] as const)
await ws.emit(user.id, '/users/ping', { hello: 'world' })

To learn more about emitting to your users, see the Emitting guides.

Registering ids from another table​

Ws.register keys sockets by id under the user prefix. Ids from another table, such as an AdminUser's, need their own prefix so they never collide with a User's, and emits to them name the same prefix:

await Ws.register(socket, adminUser.id, 'admin-user')

const adminWs = new Ws(['/admin/alert'] as const, { redisKeyPrefix: 'admin-user' })
await adminWs.emit(adminUser.id, '/admin/alert', { message: 'hello' })

Connection cap​

Under the Redis adapter, each key keeps at most three sockets by default. A fourth Ws.register for the same user — a fourth tab or device — drops the oldest socket from the registry; that socket stays connected but silently receives nothing more. Raise the cap with wsApp.set('maxConnectionsPerUser', n) (see Connection limits). The in-process adapter used in specs applies no cap.