import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets';
import { UseGuards, Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { AuthService } from '../auth/auth.service';
import { RoomSubscriptionService } from './room-subscription.service';
import { WsAuthGuard } from './ws-auth.guard';
@WebSocketGateway({ namespace: '/notifications', cors: { origin: true } })
export class NotificationsGateway
implements OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server: Server;
private readonly logger = new Logger(NotificationsGateway.name);
constructor(
private readonly auth: AuthService,
private readonly rooms: RoomSubscriptionService,
) {}
async handleConnection(socket: Socket): Promise<void> {
const token = socket.handshake.auth?.token ?? socket.handshake.query?.token;
const user = await this.auth.verifyToken(String(token ?? ''));
if (!user) {
socket.emit('error', { message: 'unauthorized' });
socket.disconnect(true);
return;
}
socket.data.user = user;
await this.rooms.subscribeToDefaults(socket, user);
this.logger.log(`socket ${socket.id} connected as user ${user.id}`);
}
handleDisconnect(socket: Socket): void {
this.logger.log(`socket ${socket.id} disconnected`);
}
@UseGuards(WsAuthGuard)
@SubscribeMessage('subscribe:topic')
async subscribeTopic(
@ConnectedSocket() socket: Socket,
@MessageBody() body: { topic: string },
): Promise<{ subscribed: string }> {
await this.rooms.join(socket, socket.data.user, `topic:${body.topic}`);
return { subscribed: body.topic };
}
}
import { Injectable, ForbiddenException } from '@nestjs/common';
import { Socket } from 'socket.io';
export interface AuthUser {
id: string;
orgId: string;
allowedTopics: string[];
}
@Injectable()
export class RoomSubscriptionService {
userRoom(userId: string): string {
return `user:${userId}`;
}
orgRoom(orgId: string): string {
return `org:${orgId}`;
}
async subscribeToDefaults(socket: Socket, user: AuthUser): Promise<void> {
await socket.join(this.userRoom(user.id));
await socket.join(this.orgRoom(user.orgId));
}
async join(socket: Socket, user: AuthUser, room: string): Promise<void> {
if (!this.canAccess(user, room)) {
throw new ForbiddenException(`cannot subscribe to ${room}`);
}
await socket.join(room);
}
async leave(socket: Socket, room: string): Promise<void> {
await socket.leave(room);
}
private canAccess(user: AuthUser, room: string): boolean {
if (room === this.userRoom(user.id)) return true;
if (room === this.orgRoom(user.orgId)) return true;
if (room.startsWith('topic:')) {
const topic = room.slice('topic:'.length);
return user.allowedTopics.includes(topic);
}
return false;
}
}
import { Injectable } from '@nestjs/common';
import { NotificationsGateway } from './notifications.gateway';
import { RoomSubscriptionService } from './room-subscription.service';
export interface NotificationPayload {
id: string;
type: string;
title: string;
body: string;
createdAt: string;
}
@Injectable()
export class NotificationsService {
constructor(
private readonly gateway: NotificationsGateway,
private readonly rooms: RoomSubscriptionService,
) {}
broadcastToUser(userId: string, payload: NotificationPayload): void {
const room = this.rooms.userRoom(userId);
this.gateway.server.to(room).emit('notification', payload);
}
broadcastToOrg(orgId: string, payload: NotificationPayload): void {
const room = this.rooms.orgRoom(orgId);
this.gateway.server.to(room).emit('notification', payload);
}
broadcastToTopic(topic: string, payload: NotificationPayload): void {
this.gateway.server.to(`topic:${topic}`).emit('notification', payload);
}
async countListeners(room: string): Promise<number> {
const sockets = await this.gateway.server.in(room).fetchSockets();
return sockets.length;
}
}
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { WsException } from '@nestjs/websockets';
import { Socket } from 'socket.io';
import { AuthService } from '../auth/auth.service';
@Injectable()
export class WsAuthGuard implements CanActivate {
constructor(private readonly auth: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const socket = context.switchToWs().getClient<Socket>();
const token =
socket.handshake.auth?.token ?? socket.handshake.query?.token;
const user = await this.auth.verifyToken(String(token ?? ''));
if (!user) {
throw new WsException('unauthorized');
}
socket.data.user = user;
return true;
}
}
This snippet shows how a NestJS application delivers per-user live notifications over a Socket.IO gateway using a room-based subscription model. The central idea is that instead of tracking individual socket ids, each authenticated connection joins one or more logical rooms (like user:42 or org:7), and broadcasts target those room names. Rooms give a natural fan-out primitive: a single emit reaches every device a user has open, and Socket.IO handles the membership bookkeeping.
In notifications.gateway.ts, the @WebSocketGateway decorator configures the namespace and CORS, and handleConnection runs on every new socket. It resolves the user from the handshake token via AuthService, disconnects unauthenticated clients, and then delegates to the subscription service to place the socket into its rooms. The @SubscribeMessage('subscribe:topic') handler lets clients opt into additional topic rooms at runtime, but only after the guard authorizes them, which prevents a client from listening to arbitrary rooms.
In room-subscription.service.ts, room naming is centralized so producers and consumers agree on the same keys. subscribeToDefaults joins the personal and org rooms, while join/leave wrap socket.join with validation so a client cannot subscribe to a room it has no claim to. Keeping this logic in a service rather than the gateway makes the rules testable and reusable from HTTP controllers.
In notifications.service.ts, broadcastToUser and broadcastToTopic are the public API other modules call. They resolve the room name and use server.to(room).emit(...), so the notification producer never needs a socket reference. This decouples the domain code that creates a notification from the transport that delivers it.
The WsAuthGuard in ws-auth.guard.ts re-validates the token on message events, because the handshake identity can be stale by the time a later message arrives. A key trade-off is that Socket.IO rooms live in a single process; scaling horizontally requires the @socket.io/redis-adapter so emit reaches sockets on other nodes. Edge cases worth noting are duplicate connections from multiple tabs (handled naturally by rooms), and cleaning up on handleDisconnect, which Socket.IO does automatically for room membership.
Related snips
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
class Comment < ApplicationRecord
belongs_to :article
belongs_to :author, class_name: "User"
validates :body, presence: true, length: { maximum: 2_000 }
Live comments with model broadcasts + turbo_stream_from
<%# private stream: turbo signs the serialized record name %>
<%= turbo_stream_from current_user %>
<section class="notifications">
<h1>Notifications</h1>
Turbo Streams + authorization: signed per-user stream name
raw_token = SecureRandom.urlsafe_base64(32)
token_digest = Digest::SHA256.hexdigest(raw_token)
PasswordReset.create!(
user: user,
token_digest: token_digest,
Secure random token generation for sessions and recovery flows
Share this code
Here's the card — post it anywhere.