Skip to content

Commit 627eb17

Browse files
fix: stop dispatching messages after close (#362)
1 parent 5fcdfee commit 627eb17

4 files changed

Lines changed: 74 additions & 0 deletions

File tree

‎.changeset/quiet-stream-close.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'eventsource': patch
3+
---
4+
5+
Stop dispatching buffered messages when an event listener closes the connection.

‎src/EventSource.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,11 @@ class EventSourceImpl extends EventTarget implements EventSource {
601601
* @internal
602602
*/
603603
#onEvent = (event: EventSourceMessage) => {
604+
// A listener can close the connection while the parser is still processing this chunk.
605+
if (this.#readyState === this.CLOSED) {
606+
return
607+
}
608+
604609
const origin = this.#redirectUrl ? this.#redirectUrl.origin : this.#url.origin
605610
// [spec] The `lastEventId` attribute is the last event ID string of the event
606611
// source, i.e. the persisted buffer (`#lastEventId`) - not the current event's `id`.

‎test/client.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,62 @@ const xOriginRedirectTest = suite === 'happy-dom' ? test.fails : test
4444
*/
4545
const onHandlerTest = suite === 'workerd' ? test.fails : test
4646

47+
const bufferedEventTypes = [
48+
{name: 'nameless', eventType: ''},
49+
{name: 'message', eventType: 'message'},
50+
{name: 'notice', eventType: 'notice'},
51+
]
52+
53+
test.each(bufferedEventTypes)(
54+
'stops dispatching buffered $name events when a listener closes the connection',
55+
async ({eventType}) => {
56+
const seen: string[] = []
57+
const onMessage = getCallCounter({name: 'first message'})
58+
const es = new OurEventSource(`${serverUrl}/message-burst?event=${eventType}`, {
59+
async fetch(url, init) {
60+
const response = await request(url, init)
61+
// Keep the real HTTP exchange, but make the chunk boundary deterministic.
62+
const body = await response.arrayBuffer()
63+
return new Response(body, {status: response.status, headers: response.headers})
64+
},
65+
})
66+
67+
es.addEventListener(eventType || 'message', (event) => {
68+
seen.push(event.data)
69+
es.close()
70+
onMessage.listener(event)
71+
})
72+
73+
try {
74+
await onMessage.waitForCallCount(1)
75+
expect(seen).toEqual(['first'])
76+
expect(es.readyState).toBe(OurEventSource.CLOSED)
77+
} finally {
78+
es.close()
79+
}
80+
},
81+
)
82+
83+
test.each(bufferedEventTypes)(
84+
'dispatches every buffered $name event while open',
85+
async ({eventType}) => {
86+
const seen: string[] = []
87+
const onMessage = getCallCounter({name: 'messages'})
88+
const es = new OurEventSource(`${serverUrl}/message-burst?event=${eventType}`, esInit)
89+
es.addEventListener(eventType || 'message', (event) => {
90+
seen.push(event.data)
91+
onMessage.listener(event)
92+
})
93+
94+
try {
95+
await onMessage.waitForCallCount(3)
96+
expect(seen).toEqual(['first', 'second', 'third'])
97+
} finally {
98+
es.close()
99+
}
100+
},
101+
)
102+
47103
test('can connect, receive message, manually disconnect', async () => {
48104
const onMessage = getCallCounter({name: 'onMessage'})
49105
const es = new OurEventSource(new URL(`${serverUrl}/`))

‎test/helpers/server.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export function handleRequest(
6161
return writeCounter(req, res)
6262
case '/mixed-ids':
6363
return writeMixedIds(req, res)
64+
case '/message-burst':
65+
return writeMessageBurst(req, res)
6466
case '/id-only':
6567
return writeIdOnly(req, res)
6668
case '/identified':
@@ -145,6 +147,12 @@ async function writeCounter(req: IncomingMessage, res: ServerResponse) {
145147
res.end()
146148
}
147149

150+
function writeMessageBurst(req: IncomingMessage, res: ServerResponse) {
151+
const event = new URL(req.url || '/', 'http://localhost').searchParams.get('event') ?? 'message'
152+
res.writeHead(200, {'Content-Type': 'text/event-stream'})
153+
res.end(['first', 'second', 'third'].map((data) => encode({event, data})).join(''))
154+
}
155+
148156
/**
149157
* Writes two messages: one with an `id` field, then one without. Per the spec, the second
150158
* event's `lastEventId` must still be `'1'`: the last event ID buffer is only updated by an

0 commit comments

Comments
 (0)