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/utf8-last-event-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'eventsource': patch
---

Fixed `Last-Event-ID` not being sent as UTF-8 on reconnect

The event ID was passed to `fetch()` as a plain string header value, but header values are byte strings. An ID containing a character above U+00FF, eg `€`, made `fetch()` throw a `TypeError` on every reconnect attempt, so the connection was never re-established. An ID with characters in the U+0080 to U+00FF range, eg `é`, was sent as a single Latin-1 byte instead of its UTF-8 encoding. The header now carries the UTF-8 bytes of the ID, as the specification requires.
27 changes: 26 additions & 1 deletion src/EventSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,11 @@ class EventSourceImpl extends EventTarget implements EventSource {
* @internal
*/
#getRequestOptions(): EventSourceFetchInit {
const lastEvent = this.#lastEventId ? {'Last-Event-ID': this.#lastEventId} : undefined
// [spec] If the last event ID string is not the empty string, set `Last-Event-ID` to the last
// event ID string, encoded as UTF-8.
const lastEvent = this.#lastEventId
? {'Last-Event-ID': utf8ToByteString(this.#lastEventId)}
: undefined

const init: EventSourceFetchInit = {
// [spec] Let `corsAttributeState` be `Anonymous`…
Expand Down Expand Up @@ -810,3 +814,24 @@ function defineEventProperty(
configurable: true,
})
}

/**
* Encodes a string as UTF-8 and returns those bytes as a "byte string": one character per byte,
* each in the range U+0000 to U+00FF.
*
* Header values are byte strings. `fetch()` throws a `TypeError` for any character above U+00FF,
* and sends a character in U+0080 to U+00FF as a single Latin-1 byte, rather than as its UTF-8
* encoding. Passing the UTF-8 bytes through as characters makes `fetch()` put exactly those
* bytes on the wire.
*
* @param value - The string to encode
* @returns The UTF-8 bytes of `value`, one per character
* @internal
*/
function utf8ToByteString(value: string): string {
let byteString = ''
for (const byte of new TextEncoder().encode(value)) {
byteString += String.fromCharCode(byte)
}
return byteString
}
27 changes: 27 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,33 @@ test('an ID from a block without data is used for reconnecting and later message
await deferClose(es)
})

const nonAsciiIds = [
{name: 'Latin-1 (U+0080 to U+00FF)', id: 'café'},
{name: 'beyond Latin-1 (above U+00FF)', id: 'café-€1'},
{name: 'outside the BMP', id: 'id-😀'},
]

test.each(nonAsciiIds)(
'sends a UTF-8 encoded `Last-Event-ID` for an ID with characters $name',
async ({id}) => {
const onMessage = getCallCounter({name: 'onMessage'})
const url = `${serverUrl}/echo-last-event-id?id=${encodeURIComponent(id)}`
const es = new OurEventSource(url, esInit)

es.addEventListener('message', onMessage.listener)

// The first message arrives together with the ID, the second is the echo from the reconnect
await onMessage.waitForCallCount(2)

expect(onMessage.lastArg).toMatchObject({
data: `Reconnected with ${id}`,
lastEventId: id,
})

await deferClose(es)
},
)

test('will not reconnect after explicit `close()`', async () => {
const onMessage = getCallCounter({name: 'onMessage'})
const onError = getCallCounter<ErrorEvent>({name: 'onError'})
Expand Down
35 changes: 35 additions & 0 deletions test/helpers/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export function handleRequest(
return writeMessageBurst(req, res)
case '/id-only':
return writeIdOnly(req, res)
case '/echo-last-event-id':
return writeEchoLastEventId(req, res)
case '/identified':
return writeIdentifiedListeners(req, res)
case '/end-after-one':
Expand Down Expand Up @@ -192,6 +194,30 @@ function writeIdOnly(req: IncomingMessage, res: ServerResponse) {
res.end()
}

/**
* Sends the event ID given in the `id` query parameter on the first connection, then closes. On the
* reconnect, echoes back the `Last-Event-ID` header, decoded as UTF-8, so that a test can check
* exactly which bytes the client put on the wire.
*/
function writeEchoLastEventId(req: IncomingMessage, res: ServerResponse) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})

const lastEventId = getLastEventIdFromUtf8Header(req)
if (lastEventId === undefined) {
const id = new URL(req.url || '/', 'http://localhost').searchParams.get('id') ?? ''
tryWrite(res, encode({retry: 50, id, data: 'First connection'}))
res.end()
return
}

tryWrite(res, encode({data: `Reconnected with ${lastEventId}`}))
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 Expand Up @@ -601,6 +627,15 @@ function getLastEventId(req: IncomingMessage): string | undefined {
return typeof lastId === 'string' ? lastId : undefined
}

/**
* Like `getLastEventId()`, but for a header value that carries UTF-8 bytes. Node hands header
* values over with one character per byte, so the bytes have to be decoded explicitly.
*/
function getLastEventIdFromUtf8Header(req: IncomingMessage): string | undefined {
const lastId = getLastEventId(req)
return lastId === undefined ? undefined : Buffer.from(lastId, 'latin1').toString('utf8')
}

function tryWrite(res: ServerResponse, chunk: string | Uint8Array) {
try {
res.write(chunk)
Expand Down
Loading