diff --git a/.changeset/utf8-last-event-id.md b/.changeset/utf8-last-event-id.md new file mode 100644 index 0000000..7dfce9f --- /dev/null +++ b/.changeset/utf8-last-event-id.md @@ -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. diff --git a/src/EventSource.ts b/src/EventSource.ts index e4dd9c6..ec24ada 100644 --- a/src/EventSource.ts +++ b/src/EventSource.ts @@ -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`… @@ -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 +} diff --git a/test/client.test.ts b/test/client.test.ts index d807576..595c30a 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -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({name: 'onError'}) diff --git a/test/helpers/server.ts b/test/helpers/server.ts index ace2733..a8e0646 100644 --- a/test/helpers/server.ts +++ b/test/helpers/server.ts @@ -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': @@ -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') @@ -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)