NestJs @Sse - event is consumed only by one client

Viewed 772

I tried the sample SSE application provided with nest.js (28-SSE), and modified the sse endpoint to send a counter:

  @Sse('sse')
  sse(): Observable<MessageEvent> {
    return interval(5000).pipe(
      map((_) => ({ data: { hello: `world - ${this.c++}` }} as MessageEvent)),
    );
  }

I expect that each client that is listening to this SSE will receive the message, but when opening multiple browser tabs I can see that each message is consumed only by one browser, so if I have three browsers open I get the following:

enter image description here

How can I get the expected behavior?

3 Answers

To achieve the behavior you're expecting you need to create a separate stream for each connection and push the data stream as you wish.

One possible minimalistic solution is below

import { Controller, Get, MessageEvent, OnModuleDestroy, OnModuleInit, Res, Sse } from '@nestjs/common';
import { readFileSync } from 'fs';
import { join } from 'path';
import { Observable, ReplaySubject } from 'rxjs';
import { map } from 'rxjs/operators';
import { Response } from 'express';

@Controller()
export class AppController implements OnModuleInit, OnModuleDestroy {
  private stream: {
    id: string;
    subject: ReplaySubject<unknown>;
    observer: Observable<unknown>;
  }[] = [];
  private timer: NodeJS.Timeout;
  private id = 0;

  public onModuleInit(): void {
    this.timer = setInterval(() => {
      this.id += 1;
      this.stream.forEach(({ subject }) => subject.next(this.id));
    }, 1000);
  }

  public onModuleDestroy(): void {
    clearInterval(this.timer);
  }

  @Get()
  public index(): string {
    return readFileSync(join(__dirname, 'index.html'), 'utf-8').toString();
  }

  @Sse('sse')
  public sse(@Res() response: Response): Observable<MessageEvent> {
    const id = AppController.genStreamId();
    // Clean up the stream when the client disconnects
    response.on('close', () => this.removeStream(id));
    // Create a new stream
    const subject = new ReplaySubject();
    const observer = subject.asObservable();
    this.addStream(subject, observer, id);

    return observer.pipe(map((data) => ({
      id: `my-stream-id:${id}`,
      data: `Hello world ${data}`,
      event: 'my-event-name',
    }) as MessageEvent));
  }

  private addStream(subject: ReplaySubject<unknown>, observer: Observable<unknown>, id: string): void {
    this.stream.push({
      id,
      subject,
      observer,
    });
  }

  private removeStream(id: string): void {
    this.stream = this.stream.filter(stream => stream.id !== id);
  }

  private static genStreamId(): string {
    return Math.random().toString(36).substring(2, 15);
  }
}

You can make a separate service for it and make it cleaner and push stream data from different places but as an example showcase this would result as shown in the screenshot below

enter image description here

This behaviour is correct. Each SSE connection is a dedicated socket and handled by a dedicated server process. So each client can receive different data. It is not a broadcast-same-thing-to-many technology.

How can I get the expected behavior?

Have a central record (e.g. in an SQL DB) of the desired value you want to send out to all the connected clients.

Then have each of the SSE server processes watch or poll that central record and send out an event each time it changes.

you just have to generate a new observable for each sse connection of the same subject

 private events: Subject<MessageEvent> = new Subject();

 constuctor(){
   timer(0, 1000).pipe(takeUntil(this.destroy)).subscribe(async (index: any)=>{
            let event: MessageEvent = { 
                id: index, 
                type: 'test', 
                retry: 30000, 
                data: {index: index}
            } as MessageEvent;
            this.events.next(event);
   });
 }

 @Sse('sse')
 public sse(): Observable<MessageEvent> {
   return this.events.asObservable();
 }

Note: I'm skipping the rest of the controller code.

Regards,

Related