Skip to content

fix: send Last-Event-ID header as UTF-8 - #367

Merged
rexxars merged 1 commit into
EventSource:mainfrom
OsamaAnsar:fix/utf8-last-event-id-header
Oct 13, 2026
Merged

rexxars merged 1 commit into
EventSource:mainfrom
OsamaAnsar:fix/utf8-last-event-id-header

Conversation

@OsamaAnsar

Copy link
Copy Markdown
Contributor

What was wrong

Last-Event-ID was set to the raw event ID string. Header values passed to fetch() are byte strings, so:

  • An ID containing a character above U+00FF (eg €) makes fetch() throw a TypeError on every reconnect attempt. #onFetchError retries with the same value forever, so the client never reconnects and readyState stays CONNECTING.
  • An ID with characters in U+0080 to U+00FF (eg é) is sent as a single Latin-1 byte rather than as UTF-8.

The HTML specification (server-sent events, "reconnection" step of the processing model) says the Last-Event-ID header is the last event ID string, encoded as UTF-8.

The fix

Encode the ID as UTF-8 and pass those bytes through as a byte string (one character per byte), so fetch() puts exactly the UTF-8 bytes on the wire. This is the only place the ID is used as a header value. A plain loop is used for the conversion rather than spreading the bytes into String.fromCharCode, so long IDs cannot hit argument limits.

Before and after

Reproduction on Node v24.19.0: a local server sends id: café-€1 and closes the response, then logs the Last-Event-ID bytes it receives (decoded as UTF-8 for readability).

Before (main):

server hit 1 raw header bytes (hex) = (none)
open | readyState 1
message "hello" lastEventId "café-€1"
error event: undefined | readyState 0
error event: Cannot convert argument to a ByteString because the character at index 5 has a value of 8364 which is greater than 255. | readyState 0
total error events 14 | server hits 1 | readyState 0

After:

server hit 1 raw header bytes (hex) = (none)
open | readyState 1
message "hello" lastEventId "café-€1"
error event: undefined | readyState 0
server hit 2 raw header bytes (hex) = 636166c3a92de282ac31 | decoded as UTF-8 = "café-€1"
open | readyState 1
total error events 1 | server hits 2 | readyState 1

With an ID in the Latin-1 range only (id: café), the server received the bytes 636166e9 before, and receives 636166c3a9 (UTF-8) after.

Tests

Added a test.each in test/client.test.ts with three IDs: Latin-1 range (café), beyond Latin-1 (café-€1) and outside the BMP (id-😀). It uses a new /echo-last-event-id route in test/helpers/server.ts, which sends the ID on the first connection and echoes the Last-Event-ID header, decoded as UTF-8, on the reconnect.

  • I reverted only the change in src/EventSource.ts and confirmed all three new tests fail (two of them with Timeout waiting for onMessage call count of 2), then restored it and confirmed they pass.
  • npm test (Node, vitest): 55 passed, 1 skipped. npm run lint (oxlint plus both tsc --noEmit runs) and oxfmt --check pass. npm run test:types, npm run test:bun (55 passed, 1 skipped) and npm run test:happy-dom (52 passed, 4 expected failures, as documented in CONTRIBUTING) pass as well.
  • I did not run the browser, iOS, Deno or workerd suites (Playwright browsers, Xcode and Deno are not set up on this machine).

A patch changeset is included, as src/ is touched.

The event ID was passed to `fetch()` as a plain string header value, but
header values are byte strings. An ID with a character above U+00FF made
`fetch()` throw a `TypeError` on every reconnect attempt, so the client
never reconnected. An ID with characters in U+0080 to U+00FF was sent as
single Latin-1 bytes instead of UTF-8.

Encode the ID as UTF-8 and pass those bytes through as a byte string, as
the specification requires.
@rexxars
rexxars merged commit fd62d79 into EventSource:main Oct 13, 2026
1 check passed
@rexxars

rexxars commented Oct 13, 2026

Copy link
Copy Markdown
Member

Thank you for your contribution!

@rexxars

rexxars commented Oct 13, 2026

Copy link
Copy Markdown
Member

Mistakenly didn't run the CI workflows on this; this crashes on Safari and workerd. I'm working on finding a solution.

@rexxars

rexxars commented Oct 13, 2026

Copy link
Copy Markdown
Member

Could not find a proper workaround for Safari, unfortunately: https://bugs.webkit.org/show_bug.cgi?id=327451
Would encourage the usage of ascii for event IDs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants