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