Skip to content

Commit eecd47e

Browse files
committed
fix: stray reconnect after close
1 parent c5fcc25 commit eecd47e

File tree

3 files changed

+75
-3
lines changed

3 files changed

+75
-3
lines changed

src/client.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ export function createEventSource(
9090
request = null
9191

9292
// We expect abort errors when the user manually calls `close()` - ignore those
93-
if (err.name !== 'AbortError' && err.type !== 'aborted') {
94-
throw err
93+
if (err.name === 'AbortError' || err.type === 'aborted') {
94+
return
9595
}
9696

9797
scheduleReconnect()

test/server.ts

+41-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import esbuild from 'esbuild'
88
import {unicodeLines} from './fixtures.js'
99

1010
const isDeno = typeof globalThis.Deno !== 'undefined'
11+
const idedListeners = new Map<string, Set<ServerResponse>>()
1112

1213
export function getServer(port: number): Promise<Server> {
1314
return new Promise((resolve, reject) => {
@@ -23,12 +24,15 @@ function onRequest(req: IncomingMessage, res: ServerResponse) {
2324
res.socket.setNoDelay(true)
2425
}
2526

26-
switch (req.url) {
27+
const path = new URL(req.url || '/', 'http://localhost').pathname
28+
switch (path) {
2729
// Server-Sent Event endpoints
2830
case '/':
2931
return writeDefault(req, res)
3032
case '/counter':
3133
return writeCounter(req, res)
34+
case '/identified':
35+
return writeIdentifiedListeners(req, res)
3236
case '/end-after-one':
3337
return writeOne(req, res)
3438
case '/slow-connect':
@@ -109,6 +113,42 @@ async function writeCounter(req: IncomingMessage, res: ServerResponse) {
109113
res.end()
110114
}
111115

116+
function writeIdentifiedListeners(req: IncomingMessage, res: ServerResponse) {
117+
const url = new URL(req.url || '/', 'http://localhost')
118+
const id = url.searchParams.get('id')
119+
if (!id) {
120+
res.writeHead(400, {
121+
'Content-Type': 'application/json',
122+
'Cache-Control': 'no-cache',
123+
Connection: 'keep-alive',
124+
})
125+
tryWrite(res, JSON.stringify({error: 'Missing "id" query parameter'}))
126+
res.end()
127+
return
128+
}
129+
130+
if ((req.headers.accept || '').includes('text/event-stream')) {
131+
const current = (idedListeners.get(id) || new Set()).add(res)
132+
idedListeners.set(id, current)
133+
res.writeHead(200, {
134+
'Content-Type': 'text/event-stream',
135+
'Cache-Control': 'no-cache',
136+
Connection: 'keep-alive',
137+
})
138+
tryWrite(res, formatEvent({data: '', retry: 250}))
139+
tryWrite(res, formatEvent({data: `${idedListeners.size}`}))
140+
res.on('close', () => (idedListeners.get(id) || new Set()).delete(res))
141+
return
142+
}
143+
144+
res.writeHead(200, {
145+
'Content-Type': 'application/json',
146+
'Cache-Control': 'no-cache',
147+
})
148+
tryWrite(res, JSON.stringify({numListeners: (idedListeners.get(id) || new Set()).size}))
149+
res.end()
150+
}
151+
112152
function writeOne(req: IncomingMessage, res: ServerResponse) {
113153
const last = getLastEventId(req)
114154
res.writeHead(last ? 204 : 200, {

test/tests.ts

+32
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,38 @@ export function registerTests(options: {
118118
await deferClose(es)
119119
})
120120

121+
test('will not reconnect after explicit close()', async () => {
122+
const request = fetch || globalThis.fetch
123+
const onMessage = getCallCounter()
124+
const onDisconnect = getCallCounter()
125+
const url = `${baseUrl}:${port}/identified?id=explicit-close-no-reconnect`
126+
const es = createEventSource({
127+
url,
128+
fetch,
129+
onMessage,
130+
onDisconnect,
131+
})
132+
133+
// Should receive a message containing the number of listeners on the given ID
134+
await onMessage.waitForCallCount(1)
135+
expect(onMessage.lastCall.lastArg).toMatchObject({data: '1'})
136+
expect(es.readyState).toBe(OPEN) // Open (connected)
137+
138+
// Explicitly disconnect. Should normally reconnect within ~250ms (server sends retry: 250)
139+
// but we'll close it before that happens
140+
es.close()
141+
expect(es.readyState).toBe(CLOSED)
142+
expect(onMessage.callCount).toBe(1)
143+
144+
// After 500 ms, there should be no clients connected to the given ID
145+
await new Promise((resolve) => setTimeout(resolve, 500))
146+
expect(await request(url).then((res) => res.json())).toMatchObject({numListeners: 0})
147+
148+
// Wait another 500 ms, just to be sure there are no slow reconnects
149+
await new Promise((resolve) => setTimeout(resolve, 500))
150+
expect(await request(url).then((res) => res.json())).toMatchObject({numListeners: 0})
151+
})
152+
121153
test('can use async iterator, reconnects transparently', async () => {
122154
const onDisconnect = getCallCounter()
123155
const url = `${baseUrl}:${port}/counter`

0 commit comments

Comments
 (0)