loading...

Angular & Node: Using Server Sent Events for downloads

elasticrash profile image Stefanos Kouroupis ・2 min read

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();
    }
}

Discussion

pic
Editor guide