Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/lasteventid-persists-without-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'eventsource': patch
---

Fixed `MessageEvent.lastEventId` being empty when a message omits the id field

The `lastEventId` attribute is the last event ID string of the event source, so an explicit `id` field persists until another one replaces it. Message events were dispatched with the current event's own `id` instead of that buffer, which left `lastEventId` empty on every event that omitted `id`, even though the buffer still held the earlier value and would have been sent as `Last-Event-ID` on reconnect.
6 changes: 5 additions & 1 deletion src/EventSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,11 @@ class EventSourceImpl extends EventTarget implements EventSource {
}

const origin = this.#redirectUrl ? this.#redirectUrl.origin : this.#url.origin
const lastEventId = event.id || ''
// [spec] The `lastEventId` attribute is the last event ID string of the event
// source, i.e. the persisted buffer (`#lastEventId`) - not the current event's `id`.
// The buffer is only updated by an explicit `id` field (above) and must survive an
// event that omits `id`.
const lastEventId = this.#lastEventId ?? ""

const messageEvent = new MessageEvent(event.event || 'message', {
data: event.data,
Expand Down
28 changes: 28 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,34 @@ test('will reconnect with last received message id if server disconnects', async
origin: serverOrigin,
})
expect(onMessage.callCount).toBe(8)
await deferClose(es)
})

test('message event `lastEventId` persists when a later event omits the `id` field', async () => {
Comment thread
rexxars marked this conversation as resolved.
// Record every event, not just the most recent: the two events arrive back to back, so
// `lastArg` would already point at the second one by the time the first is asserted.
const seen: MessageEvent[] = []
const onMessage = getCallCounter({name: 'onMessage', onCall: () => {}})
const es = new OurEventSource(`${serverUrl}/mixed-ids`, esInit)

es.addEventListener('message', (event) => seen.push(event as MessageEvent))
es.addEventListener('message', onMessage.listener)

await onMessage.waitForCallCount(2)

// First event carries `id: 1`, which updates the last event ID buffer.
expect(seen[0], 'first message').toMatchObject({
data: 'First, with id',
lastEventId: '1',
})

// The second event omits the `id` field. Per the spec ("dispatch the event" initializes
// `lastEventId` to the last event ID string, and only an `id` field updates that buffer),
// the event must still carry `lastEventId: '1'` - not an empty string.
expect(seen[1], 'second message').toMatchObject({
data: 'Second, without id',
lastEventId: '1',
})

await deferClose(es)
})
Expand Down
20 changes: 20 additions & 0 deletions test/helpers/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export function handleRequest(
return writeDefault(req, res)
case '/counter':
return writeCounter(req, res)
case '/mixed-ids':
return writeMixedIds(req, res)
case '/identified':
return writeIdentifiedListeners(req, res)
case '/end-after-one':
Expand Down Expand Up @@ -141,6 +143,24 @@ async function writeCounter(req: IncomingMessage, res: ServerResponse) {
res.end()
}

/**
* Writes two messages: one with an `id` field, then one without. Per the spec, the second
* event's `lastEventId` must still be `'1'`: the last event ID buffer is only updated by an
* explicit `id` field and is not reset when an event omits it.
*/
function writeMixedIds(_req: IncomingMessage, res: ServerResponse) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})

tryWrite(res, encode({id: '1', data: 'First, with id'}))
tryWrite(res, encode({data: 'Second, without id'}))

res.end()
}

async function writeIdentifiedListeners(req: IncomingMessage, res: ServerResponse) {
const url = new URL(req.url || '/', 'http://localhost')
const clientId = url.searchParams.get('client-id')
Expand Down