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
5 changes: 5 additions & 0 deletions .changeset/fix-workerd-event-id-encoding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eventsource': patch
---

Avoid double-encoding non-ASCII event IDs when reconnecting with Cloudflare Workers' native `fetch`.
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@ The browser tests need Playwright's browsers installed once, with `npx playwrigh

The provider (`test/helpers/iosSimulator.ts`) drives the simulator with `simctl` alone - Safari there is opened with `simctl openurl` and reports back over the websocket Vitest already opens, so no WebDriver or Appium stack is involved. That also means nothing in the suite can drive the page: keep the tests free of Vitest's browser locators and `userEvent`, which this provider does not implement.

Every environment gates CI. Two of them deviate from the rest in ways that are the runtime's doing rather than ours, and those tests are asserted as known failures with `test.fails` rather than skipped or excluded, so each one turns red - prompting removal of the workaround - once the runtime is fixed:
Use `test.fails` for runtime bugs when a future fix must turn the test red and prompt removal of the workaround:

- **workerd**, seven of the `on*` handler tests. Its `EventTarget` dispatches `on<type>` handler properties itself, on top of the `addEventListener` call our `on*` setters make, so those handlers fire more than once, assigning `null` only removes one registration, and they fire ahead of listeners registered before them ([cloudflare/workerd#6022](https://github.com/cloudflare/workerd/issues/6022)). The two `on*` tests that register a single handler and check that it fired are unaffected and run normally.
- **happy-dom**, the four cross-origin redirect tests. It does not strip `Authorization` when a request is redirected to a different origin, as [the fetch spec requires](https://fetch.spec.whatwg.org/#http-redirect-fetch).

For Safari/WebKit, skip only the non-ASCII `Last-Event-ID` round-trip cases. Keep the Unicode parsing and header-construction assertions active. Document this limitation in the README.

The per-runtime message pattern in the extended-properties test is the other place runtimes differ: a refused connection is reported as `fetch failed` on Node, `connect ECONNREFUSED …` under happy-dom, `Network connection lost.` on workerd, and something different again in each browser engine. That one is only wording, so the pattern accepts all of them.

The browser suite is the one place where the endpoints are not served by a standalone server. Vitest serves the test page from its own Vite server, so the endpoints are mounted onto that same server (`test/helpers/ssePlugin.ts`) to keep the page and the endpoints same-origin. Serving them separately would make every request cross-origin and silently change what the CORS, cookie and redirect tests actually assert.
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ npm install --save eventsource

- Node.js >= 22.12
- Chrome >= 84
- Safari >= 15
- Safari >= 15 ([non-ASCII event ID limitation](#safari-and-webkit))
- Firefox >= 105
- Edge >= 84
- Deno >= 2
Expand Down Expand Up @@ -98,6 +98,12 @@ error TS2304: Cannot find name 'EventTarget'.
error TS2304: Cannot find name 'MessageEvent'.
```

### Safari and WebKit

Safari/WebKit encodes non-ASCII `Last-Event-ID` header values as UTF-8 a second time, so the server receives a different ID on reconnect. This affects accented characters, currency symbols and emoji. Event data and `MessageEvent.lastEventId` retain their original Unicode values.

For the widest client compatibility, we recommend that servers use ASCII-only values in the event stream's `id` field. See [WebKit's header encoding](https://github.com/WebKit/WebKit/blob/main/Source/WebCore/platform/network/cf/ResourceRequestCFNet.h#L76-L85) for the underlying behavior.

### Cloudflare Workers

Cloudflare Workers require `compatibilityDate: '2024-11-11'` or later, and `addEventListener()` instead of the `onmessage`, `onopen` and `onerror` properties.
Expand All @@ -114,6 +120,8 @@ eventSource.addEventListener('message', (event) => console.log(event.data))

workerd also drops `origin` and `lastEventId` from the `MessageEvent` constructor. This module assigns them itself, so both are correct on Workers today; the workaround goes away once [workerd#6995](https://github.com/cloudflare/workerd/pull/6995) lands.

Workers' native `fetch` encodes header values as UTF-8 itself. This module passes it the original event ID to avoid encoding twice. Custom `fetch` implementations receive a UTF-8 byte string; wrappers that forward to Workers' native `fetch` must decode `Last-Event-ID` back to Unicode first.

## Migrating from v1 / v2

See [MIGRATION.md](MIGRATION.md#v2-to-v3) for a detailed migration guide.
Expand Down
9 changes: 8 additions & 1 deletion src/EventSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,8 +561,15 @@ class EventSourceImpl extends EventTarget implements EventSource {
#getRequestOptions(): EventSourceFetchInit {
// [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.
// workerd's native fetch encodes header values as UTF-8 itself.
const lastEvent = this.#lastEventId
? {'Last-Event-ID': utf8ToByteString(this.#lastEventId)}
? {
'Last-Event-ID':
this.#fetch === globalThis.fetch &&
globalThis.navigator?.userAgent === 'Cloudflare-Workers'
? this.#lastEventId
: utf8ToByteString(this.#lastEventId),
}
: undefined

const init: EventSourceFetchInit = {
Expand Down
58 changes: 47 additions & 11 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
crossOriginUrl,
esInit,
hasBrowserSemantics,
isWebKit,
request,
serverOrigin,
serverUrl,
Expand Down Expand Up @@ -481,12 +482,47 @@ test('an ID from a block without data is used for reconnecting and later message
})

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-😀'},
{name: 'Latin-1 (U+0080 to U+00FF)', id: 'café', header: 'caf\xc3\xa9'},
{
name: 'beyond Latin-1 (above U+00FF)',
id: 'café-€1',
header: 'caf\xc3\xa9-\xe2\x82\xac1',
},
{name: 'outside the BMP', id: 'id-😀', header: 'id-\xf0\x9f\x98\x80'},
{
name: 'joined emoji with a skin tone modifier',
id: 'id-👩🏽‍💻',
header: 'id-\xf0\x9f\x91\xa9\xf0\x9f\x8f\xbd\xe2\x80\x8d\xf0\x9f\x92\xbb',
},
]

test.each(nonAsciiIds)(
'provides a UTF-8 `Last-Event-ID` byte string to custom fetch for characters $name',
async ({id, header}) => {
let lastEventIdHeader: string | undefined
const onMessage = getCallCounter({name: 'onMessage'})
const url = `${serverUrl}/echo-last-event-id?id=${encodeURIComponent(id)}`
const es = new OurEventSource(url, {
fetch(url, init) {
lastEventIdHeader = init.headers['Last-Event-ID']
return request(url, init)
},
})

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

try {
await onMessage.waitForCallCount(2)
expect(lastEventIdHeader).toBe(header)
expect(onMessage.lastArg.lastEventId).toBe(id)
} finally {
es.close()
}
},
)

// WebKit UTF-8-encodes Last-Event-ID a second time. Keep the byte-string assertions above active.
test.skipIf(isWebKit).each(nonAsciiIds)(
'sends a UTF-8 encoded `Last-Event-ID` for an ID with characters $name',
async ({id}) => {
const onMessage = getCallCounter({name: 'onMessage'})
Expand All @@ -495,15 +531,15 @@ test.each(nonAsciiIds)(

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,
})
try {
// The first message arrives together with the ID, the second is the echo from the reconnect
await onMessage.waitForCallCount(2)

await deferClose(es)
expect(onMessage.lastArg.lastEventId).toBe(id)
expect(onMessage.lastArg.data).toBe(`Reconnected with ${id}`)
} finally {
es.close()
}
},
)

Expand Down
3 changes: 3 additions & 0 deletions test/helpers/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ declare module 'vitest' {
suite: Suite
/** Port the test endpoints are reachable on. */
port: number
/** Whether the browser uses WebKit, including Safari on the iOS simulator. */
isWebKit?: boolean
}
}

export const suite: Suite = inject('suite')
export const isWebKit = inject('isWebKit') === true

/**
* Origin the test endpoints are served from.
Expand Down
2 changes: 1 addition & 1 deletion vitest.browser.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default defineConfig({
instances: [
{browser: 'chromium', headless: true},
{browser: 'firefox', headless: true},
{browser: 'webkit', headless: true},
{browser: 'webkit', headless: true, provide: {isWebKit: true}},
],
},
},
Expand Down
2 changes: 1 addition & 1 deletion vitest.ios.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default defineConfig({
plugins: [ssePlugin()],
test: {
...sharedConfig,
provide: {port: BROWSER_PORT, suite: 'browser'},
provide: {port: BROWSER_PORT, suite: 'browser', isWebKit: true},
browser: {
enabled: true,
provider: iosSimulator(),
Expand Down
Loading