From 538c69f5e79d58a92f81a7f437d203cbb391999d Mon Sep 17 00:00:00 2001 From: Maxime Petazzoni Date: Sun, 4 Feb 2024 11:26:31 -0800 Subject: [PATCH] Implement Last-Event-ID support (closes #50) This change implements support for the `lastEventId` attribute and setting the `Last-Event-ID` header on reconnection requests, per the EventSource specification. --- README.md | 12 ++++++++- lib/sse.js | 14 ++++++++-- lib/sse.test.js | 70 ++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a2f7a0c..55b84e5 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ is the UNIX timestamp of the _reception_ of the event. Additionally, the events will have the following fields: - `id`: the event ID, if present; `null` otherwise +- `lastEventId`: the last seen event ID, or the empty string if no event + with an ID was received - `data`: the event data, unparsed `SSE`, like `EventSource`, will emit the following events: @@ -155,10 +157,18 @@ request that the outgoing HTTP request be made with a CORS credentials mode of `include`, as per the [HTML Living Standard](https://fetch.spec.whatwg.org/#concept-request-credentials-mode). +## Reconnecting after failure + +SSE.js does not (yet) automatically reconnect on failure; you can listen +for the `abort` event and decide whether to reconnect and restart the +event stream by calling `stream()`. + +SSE.js _will_ set the `Last-Event-ID` header on reconnection to the last +seen event ID value (if any), as per the EventSource specification. + ## TODOs and caveats - Internet Explorer 11 does not support arbitrary values in `CustomEvent`s. A dependency on `custom-event-polyfill` is necessary for IE11 compatibility. - Improve `XmlHttpRequest` error handling and connection states -- Automatically reconnect with `Last-Event-ID` diff --git a/lib/sse.js b/lib/sse.js index c2cc9e4..b992b4f 100644 --- a/lib/sse.js +++ b/lib/sse.js @@ -48,6 +48,8 @@ var SSE = function (url, options) { this.progress = 0; /** @type {string} */ this.chunk = ''; + /** @type {string} */ + this.lastEventId = ''; /** * @type AddEventListener @@ -222,9 +224,14 @@ var SSE = function (url, options) { } }.bind(this)); - var event = new CustomEvent(e.event || 'message'); - event.data = e.data || ''; + if (e.id !== null) { + this.lastEventId = e.id; + } + + const event = new CustomEvent(e.event || 'message'); event.id = e.id; + event.data = e.data || ''; + event.lastEventId = this.lastEventId; return event; }; @@ -261,6 +268,9 @@ var SSE = function (url, options) { for (let header in this.headers) { this.xhr.setRequestHeader(header, this.headers[header]); } + if (this.lastEventId.length > 0) { + this.xhr.setRequestHeader("Last-Event-ID", this.lastEventId); + } this.xhr.withCredentials = this.withCredentials; this.xhr.send(this.payload); }; diff --git a/lib/sse.test.js b/lib/sse.test.js index b330c09..5d382e5 100644 --- a/lib/sse.test.js +++ b/lib/sse.test.js @@ -115,6 +115,21 @@ describe('SSE Lifecycle', () => { sse.xhr.trigger('abort', {}); expect(sse.readyState).toBe(sse.CLOSED); }); + + it('should sent Last-Event-ID on reconnection', () => { + sse.stream(); + expect(sse.xhr.setRequestHeader).toHaveBeenCalledTimes(0); + sse.xhr.responseText = 'id: event-1\ndata: Test message\n\n'; + sse.xhr.trigger('progress', {}); + expect(sse.lastEventId).toBe('event-1'); + + sse.xhr.trigger('abort', {}); + expect(sse.readyState).toBe(sse.CLOSED); + expect(sse.lastEventId).toBe('event-1'); + + sse.stream(); + expect(sse.xhr.setRequestHeader).toHaveBeenCalledWith('Last-Event-ID', 'event-1'); + }); }); describe('SSE Event handling and Listeners', () => { @@ -147,11 +162,29 @@ describe('SSE Event handling and Listeners', () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener.mock.calls[0][0].data).toBe('Test message'); expect(listener.mock.calls[0][0].event).toBe(undefined); - expect(listener.mock.calls[0][0].id).toBe("1"); + expect(listener.mock.calls[0][0].id).toBe('1'); + expect(listener.mock.calls[0][0].lastEventId).toBe('1'); }) it('should handle multiple data events', () => { - sse.xhr.responseText = 'data: First message\n\n'; + sse.xhr.responseText = 'id: id1\ndata: First message\n\n'; + sse.xhr.trigger('progress', {}); + sse.xhr.responseText += 'id: id2\ndata: Second message\n\n'; + sse.xhr.trigger('progress', {}); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener.mock.calls[0][0].data).toBe('First message'); + expect(listener.mock.calls[0][0].event).toBe(undefined); + expect(listener.mock.calls[0][0].id).toBe('id1'); + expect(listener.mock.calls[0][0].lastEventId).toBe('id1'); + expect(listener.mock.calls[1][0].data).toBe('Second message'); + expect(listener.mock.calls[1][0].event).toBe(undefined); + expect(listener.mock.calls[1][0].id).toBe('id2'); + expect(listener.mock.calls[1][0].lastEventId).toBe('id2'); + }); + + it('should set lastEventId only when id field is in the event', () => { + sse.xhr.responseText = 'id: id1\ndata: First message\n\n'; sse.xhr.trigger('progress', {}); sse.xhr.responseText += 'data: Second message\n\n'; sse.xhr.trigger('progress', {}); @@ -159,10 +192,41 @@ describe('SSE Event handling and Listeners', () => { expect(listener).toHaveBeenCalledTimes(2); expect(listener.mock.calls[0][0].data).toBe('First message'); expect(listener.mock.calls[0][0].event).toBe(undefined); - expect(listener.mock.calls[0][0].id).toBe(null); + expect(listener.mock.calls[0][0].id).toBe('id1'); + expect(listener.mock.calls[0][0].lastEventId).toBe('id1'); expect(listener.mock.calls[1][0].data).toBe('Second message'); expect(listener.mock.calls[1][0].event).toBe(undefined); expect(listener.mock.calls[1][0].id).toBe(null); + expect(listener.mock.calls[1][0].lastEventId).toBe('id1'); + }); + + it('should reset lastEventId when id is empty', () => { + sse.xhr.responseText = 'data: First message\n\n'; + sse.xhr.trigger('progress', {}); + sse.xhr.responseText += 'data: Second message\nid: id2\n\n'; + sse.xhr.trigger('progress', {}); + sse.xhr.responseText += 'data: Third message\n\n'; + sse.xhr.trigger('progress', {}); + sse.xhr.responseText += 'data: Fourth message\nid\n\n'; + sse.xhr.trigger('progress', {}); + + expect(listener).toHaveBeenCalledTimes(4); + expect(listener.mock.calls[0][0].data).toBe('First message'); + expect(listener.mock.calls[0][0].event).toBe(undefined); + expect(listener.mock.calls[0][0].id).toBe(null); + expect(listener.mock.calls[0][0].lastEventId).toBe(''); + expect(listener.mock.calls[1][0].data).toBe('Second message'); + expect(listener.mock.calls[1][0].event).toBe(undefined); + expect(listener.mock.calls[1][0].id).toBe('id2'); + expect(listener.mock.calls[1][0].lastEventId).toBe('id2'); + expect(listener.mock.calls[2][0].data).toBe('Third message'); + expect(listener.mock.calls[2][0].event).toBe(undefined); + expect(listener.mock.calls[2][0].id).toBe(null); + expect(listener.mock.calls[2][0].lastEventId).toBe('id2'); + expect(listener.mock.calls[3][0].data).toBe('Fourth message'); + expect(listener.mock.calls[3][0].event).toBe(undefined); + expect(listener.mock.calls[3][0].id).toBe(''); + expect(listener.mock.calls[3][0].lastEventId).toBe(''); }); it('should handle repeat data elements', () => {