InteractiveFrameworks

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.

  1. Write AuditInterceptor: once the handler has answered, it records Ada shortlist:add, the sender's handshake auth.name and the event's name, in the AuditLog.
  2. Every message to CatsGateway goes through it.
  3. Make Shortlist one per connection: created when the visitor connects, gone when they leave, with the visitor's socket injected so that show() names its owner, the handshake's auth.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, because Shortlist is still a singleton shared by every connection.
  • Internal server error for every message, and the console says Nest 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 class Socket, 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 where getPattern() was meant.

Remember

  • Interceptors run around each message; context.switchToWs() gives the socket, the payload and the event name.
  • tap() after next.handle() runs once the handler has answered; map() changes the answer.
  • scope: Scope.REQUEST means once per connection over a socket, with the socket as @Inject(REQUEST); injecting REQUEST alone 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.