Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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: 6 additions & 1 deletion src/EventSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,12 @@ 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`, so emitting `event.id || ''` here wrongly blanked it after
// such an event.
Comment thread
rexxars marked this conversation as resolved.
Outdated
const lastEventId = this.#lastEventId ?? ""

const messageEvent = new MessageEvent(event.event || 'message', {
data: event.data,
Expand Down
45 changes: 21 additions & 24 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,34 +318,31 @@ test('message event contains correct properties', async () => {
await deferClose(es)
})

test('will reconnect with last received message id if server disconnects', async () => {
const onMessage = getCallCounter({name: 'onMessage'})
const onError = getCallCounter<ErrorEvent>({name: 'onError'})
const url = `${serverUrl}/counter`
const es = new OurEventSource(url, esInit)
es.addEventListener('counter', onMessage.listener)
es.addEventListener('error', onError.listener)

// While still receiving messages (we receive 3 at a time before it disconnects)
await onMessage.waitForCallCount(1)
expect(es.readyState, 'readyState').toBe(OurEventSource.OPEN) // Open (connected)
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)

// While waiting for reconnect (after 3 messages it will disconnect and reconnect)
await onError.waitForCallCount(1)
expect(es.readyState, 'readyState').toBe(OurEventSource.CONNECTING) // Connecting (reconnecting)
expect(onMessage.callCount).toBe(3)
await onMessage.waitForCallCount(2)

// Will reconnect infinitely, stop at 8 messages
await onMessage.waitForCallCount(8)
// First event carries `id: 1`, which updates the last event ID buffer.
expect(seen[0], 'first message').toMatchObject({
data: 'First, with id',
lastEventId: '1',
})

expect(es.url).toBe(url)
expect(onMessage.lastArg).toMatchObject({
data: 'Counter is at 8',
type: 'counter',
lastEventId: '8',
origin: serverOrigin,
// 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',
})
expect(onMessage.callCount).toBe(8)

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