Angular & Node: Using Server Sent Events for downloads

Stefanos Kouroupis - Sep 11 '19 - - Dev Community

Let's start with the Angular Side of our solution

The following two code snippets are part of the same Angular services. Here the function connect creates a new EventSource and forwards all the messages it receives from the backend API into an observer.

public eventSource;
public connect(url): Observable<any> {
    return Observable.create((observer) => {
      const es = new EventSource(url);

      this.eventSource = es;
      es.onmessage = (event) => {
        this._zone.run(() => observer.next(
          event.data
        ));
      };

      es.onerror = (error) => {
        observer.error(error);
      };
    });
  }

Next I create two arbitrary abbreviations EOS (end of stream) and BOS (beginning of stream), not really necessary to be honest, but sometimes useful, especially if the back-end is running long running queries. By sending the BOS immediately you are making the client to receive the response headers on the moment of the request.

Then I combine the data in the messages and use an old trick to trigger a download (creating an html element, and clicking it).

private beginningOfStream: string = "BOS"
private endOfStream: string = "EOS"

 public async stream(url:string): Promise<any> {
    const filename = `export_${moment().format('L').replace(/\//g, "")}_${moment().format('HHmm')}.csv`;

    return new Promise(async (resolve, reject) => {
      try {
        let data = '';
        this.connect(url).subscribe((response) => {
          switch (response) {
            case this.beginningOfStream:
              break;
            case this.endOfStream:
              const blob = new Blob([data], { type: 'application/txt' });
              const url = window.URL.createObjectURL(blob);
              const a = document.createElement('a');
              a.href = url;
              a.download = filename;
              document.body.appendChild(a);
              a.click();
              this.end();
              resolve({ info: 'success' });
              break;
            default:
              data += JSON.parse(response);
          }
        }, (error) => {
          if (this.eventSource) {
             this.eventSource.close();
          }
          reject(error);
        });
      } catch (error) {
        console.log(`Error occurred: ${error}`);
        if (this.eventSource) {
           this.eventSource.close();
        }
        reject(error);
      }
    });
  }

and finish with the Node Side of our solution

This is my sample Express Route. Now I the way I use the combination of Express+Typescript is slightly awkward but it works nicely. Maybe that will make another good post.

But, at the end of the day, it's quite obvious what I am trying to achieve.
I am creating the headers of the event-stream and I am sending messages back to the client, by keeping the connection alive.

export class DataRoute {
    public router = Router() as Router;

    constructor() {
        this.router.use((req, res: any, next) => {
            const successs = 200;
            res.sseSetup = () => {
                res.writeHead(successs, {
                    "Content-Type": "text/event-stream",
                    "Cache-Control": "no-cache",
                    "Connection": "keep-alive"
                });
                res.connection.setTimeout(0);
            };

            res.sseSend = (data: any) => {
                res.write("data: " + data +
                    "\n\n", "utf8", () => {
                        if (res.flushHeaders) {
                            res.flushHeaders();
                        }
                    });
            };
            next();
        });

        this.router.get("/endpoint", (req, res, next) => {
            const fileName = `export${moment().format("L").replace(/\//g, "-")}.csv`;
            res.setHeader("Content-disposition", `attachment; filename=${fileName}`);
            res["sseSetup"]();
            res["sseSend"]("BOS");

            data.forEach(function (element) {
                res["sseSend"](JSON.stringify(element));
            });

            this.closeStream(res);
        });
    }

    private closeStream(res: any) {
        res.sseSend("EOS");
        res.end();
    }
}
. . . . . . . . . . . . . . . . . . . . . . . . .