Interceptors and per-connection state
Run code around every message, and keep state for each connection: an interceptor that audits who sent what through the WebSocket execution context, and a request-scoped provider that Nest 12 creates once per connection, with that connection's socket injected.
What you'll learn
- Write an interceptor for a gateway that reads the socket, the payload and the event name through context.switchToWs()
- Bind an interceptor to a gateway with @UseInterceptors(), and know it runs around each message, not around the connection
- Keep per-connection state in a provider with scope: Scope.REQUEST, which Nest 12 creates for each connection, with the socket injected as REQUEST
- Know that a gateway that injects such a provider becomes per-connection too
Two things a live desk needs that no single handler should carry. The shelter's manager wants an audit trail: who sent what, for every message, without each handler remembering to write it down. And visitors want a shortlist, the cats they are thinking about, which belongs to them for as long as they are connected and to nobody else. The first is a job for an interceptor, which runs around every handler. The second is per-connection state, which Nest 12 lets a provider hold.
Interceptors on a gateway
There is no difference between a WebSocket interceptor and an HTTP one: the same NestInterceptor, bound with @UseInterceptors() on a handler or the gateway class, or globally. It runs around each message, not around the connection: intercept() is called when a message arrives, next.handle() runs the pipes and the handler, and the Observable it returns carries the handler's answer before it is sent.
What changes is the context. context.switchToWs() gives the socket with getClient(), the payload with getData(), and the event name with getPattern():
@Injectable()
export class SlowMessageInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const started = Date.now();
const event = context.switchToWs().getPattern();
return next.handle().pipe(tap(() => this.metrics.time(event, Date.now() - started)));
}
}
tap() runs after the handler has answered, and only if it answered: a handler that throws goes to the error path, which tap(() => …) does not see. map() here would change the answer itself, the acknowledgement or each event's value, which is how a gateway wraps every reply in an envelope.
State that belongs to a connection
The gateway is a singleton: one instance for the whole app, shared by every client. State kept on it, like the visitors map of lesson 2, has to be keyed by socket and cleaned up on disconnect, by hand. Nest 12 offers the other way round: a provider with scope: Scope.REQUEST is created once per connection and gets that connection's socket injected as REQUEST:
@Injectable({ scope: Scope.REQUEST })
export class Conversation {
private readonly messages: string[] = [];
constructor(@Inject(REQUEST) private readonly client: Socket) {}
add(message: string): number {
this.messages.push(message);
return this.messages.length;
}
}
The instance lives as long as the connection does, so it can hold anything about this one visitor without keys or clean-up: when they disconnect, it goes; when they come back, they get a new one. Over HTTP the same scope means once per request; over a socket the "request" is the whole connection.
Scope travels up the injection chain. REQUEST is itself request-scoped, so a provider that injects it is per-connection even without the scope option; declaring the scope says out loud what the class is. And a gateway that injects a request-scoped provider is created per connection too. That is fine for handlers. A singleton that must stay shared, a log, a store, stays its own provider, injected beside the scoped one.
Your task
AuditLog is a singleton holding a list of entries; Shortlist keeps cats; the gateway uses both.
- Write
AuditInterceptor: once the handler has answered, it recordsAda shortlist:add, the sender's handshakeauth.nameand the event's name, in theAuditLog. - Every message to
CatsGatewaygoes through it. - Make
Shortlistone per connection: created when the visitor connects, gone when they leave, with the visitor's socket injected so thatshow()names its owner, the handshake'sauth.name.
Then connect two clients in the Socket mode and shortlist different cats from each.
When it fails
acknowledged with {"owner":"Bob","cats":["Tom","Luna"]}: Bob sees Ada's cats, becauseShortlistis still a singleton shared by every connection.Internal server errorfor every message, and the console saysNest can't resolve dependencies of the Shortlist (?). Please make sure that the argument Socket at index [0] is available in the CatsModule module.: the socket is a constructor parameter without@Inject(REQUEST), so Nest looks for a provider of the classSocket, and looks only when the first message needs a shortlist.acknowledged with []for the audit log: the interceptor is not bound to the gateway, so nothing records.- The log names the payload, not the event:
getData()was used wheregetPattern()was meant.
Remember
- Interceptors run around each message;
context.switchToWs()gives the socket, the payload and the event name. tap()afternext.handle()runs once the handler has answered;map()changes the answer.scope: Scope.REQUESTmeans once per connection over a socket, with the socket as@Inject(REQUEST); injectingREQUESTalone has the same effect.- A gateway that injects a request-scoped provider is created per connection too; keep shared state in singletons.
Stuck? Show a hint
audit.interceptor.ts: const ws = context.switchToWs(); the name is ws.getClient<Socket>().handshake.auth.name; return next.handle().pipe(tap(() => this.auditLog.record(`${name} ${ws.getPattern()}`))). Gateway: @UseInterceptors(AuditInterceptor). shortlist.ts: @Injectable({ scope: Scope.REQUEST }) and constructor(@Inject(REQUEST) private readonly client: Socket); owner is this.client.handshake.auth.name.