diff --git a/.changeset/lasteventid-persists-without-id.md b/.changeset/lasteventid-persists-without-id.md new file mode 100644 index 0000000..ba07d7a --- /dev/null +++ b/.changeset/lasteventid-persists-without-id.md @@ -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. diff --git a/src/EventSource.ts b/src/EventSource.ts index b878b43..60f29e9 100644 --- a/src/EventSource.ts +++ b/src/EventSource.ts @@ -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, diff --git a/test/client.test.ts b/test/client.test.ts index 3eb2ae4..91cf743 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -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 () => { + // 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) }) diff --git a/test/helpers/server.ts b/test/helpers/server.ts index ec72743..0dadcf8 100644 --- a/test/helpers/server.ts +++ b/test/helpers/server.ts @@ -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': @@ -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')