Skip to content

Commit

Permalink
Implement Last-Event-ID support (closes #50)
Browse files Browse the repository at this point in the history
This change implements support for the `lastEventId` attribute and
setting the `Last-Event-ID` header on reconnection requests, per the
EventSource specification.
  • Loading branch information
mpetazzoni committed Feb 4, 2024
1 parent 4cf1158 commit 538c69f
Show file tree
Hide file tree
Showing 3 changed files with 90 additions and 6 deletions.
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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`
14 changes: 12 additions & 2 deletions lib/sse.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ var SSE = function (url, options) {
this.progress = 0;
/** @type {string} */
this.chunk = '';
/** @type {string} */
this.lastEventId = '';

/**
* @type AddEventListener
Expand Down Expand Up @@ -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;
};

Expand Down Expand Up @@ -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);
};
Expand Down
70 changes: 67 additions & 3 deletions lib/sse.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -147,22 +162,71 @@ 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', {});

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', () => {
Expand Down

0 comments on commit 538c69f

Please sign in to comment.