From ea065ef47c75c2ed6ff7105256b239d346b292cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:52:58 +0000 Subject: [PATCH 1/5] fix(fetch): treat SSE messages without an event type as message events `toAsyncIteratorObject` only handled messages whose `event:` field was `message`, `error` or `close`, so a message with no `event:` field was silently dropped. Per the SSE spec an absent or empty event type means `message`, which is what most third-party SSE servers send. Resolving such a response through the fetch, node or aws-lambda adapters lost those messages, and a stream with no `event:` lines looked empty. Unnamed messages are now dispatched as `message` events when they carry data. Unnamed messages without data, such as comment-only keep-alives or `id`/`retry`-only messages, are still skipped, as the spec requires. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NVk6KTeqLJfBMM1aWsSHZq --- packages/fetch/src/event-stream.test.ts | 38 +++++++++++++++++++++++++ packages/fetch/src/event-stream.ts | 8 +++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/fetch/src/event-stream.test.ts b/packages/fetch/src/event-stream.test.ts index a1e57855..2c281bd3 100644 --- a/packages/fetch/src/event-stream.test.ts +++ b/packages/fetch/src/event-stream.test.ts @@ -110,6 +110,44 @@ describe('toAsyncIteratorObject', () => { expect(await generator.next()).toEqual({ done: true, value: undefined }) }) + it('treats messages without an event type as message events', async () => { + const stream = new ReadableStream({ + async pull(controller) { + controller.enqueue('data: {"order": 1}\nid: id-1\nretry: 10000\n\n') + controller.enqueue(': ping\nid: id-ignored\nretry: 20000\n\n') + controller.enqueue('event: message\ndata: {"order": 2}\n\n') + controller.enqueue('event:\ndata: {"order": 3}\nid: id-3\n\n') + controller.enqueue('data: 4\n\n') + controller.enqueue('data:\n\n') + controller.close() + }, + }).pipeThrough(new TextEncoderStream()) + + const generator = toAsyncIteratorObject(stream) + + expect(await generator.next()).toSatisfy(({ done, value }) => { + expect(done).toEqual(false) + expect(value).toStrictEqual({ order: 1 }) + expect(getEventMeta(value)).toEqual({ id: 'id-1', retry: 10000 }) + + return true + }) + + expect(await generator.next()).toEqual({ done: false, value: { order: 2 } }) + + expect(await generator.next()).toSatisfy(({ done, value }) => { + expect(done).toEqual(false) + expect(value).toStrictEqual({ order: 3 }) + expect(getEventMeta(value)).toEqual({ id: 'id-3' }) + + return true + }) + + expect(await generator.next()).toEqual({ done: false, value: 4 }) + expect(await generator.next()).toEqual({ done: false, value: undefined }) + expect(await generator.next()).toEqual({ done: true, value: undefined }) + }) + it('with empty stream', async () => { const generator = toAsyncIteratorObject(null) expect(generator).toSatisfy(isAsyncIteratorObject) diff --git a/packages/fetch/src/event-stream.ts b/packages/fetch/src/event-stream.ts index 0b34cc22..46d48789 100644 --- a/packages/fetch/src/event-stream.ts +++ b/packages/fetch/src/event-stream.ts @@ -27,7 +27,13 @@ export function toAsyncIteratorObject( return { done: true, value: undefined } } - switch (value.event) { + /** + * Per the SSE spec, a message with no (or an empty) event type is a 'message' event, + * but an unnamed message without data (e.g. a comment-only keep-alive) is not dispatched. + */ + const event = value.event || (value.data === undefined ? undefined : 'message') + + switch (event) { case 'message': { let message = parseEmptyableJSON(value.data) From 304582ae01b6b990fabb168ae18cf9d703ede1f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:25:03 +0000 Subject: [PATCH 2/5] refactor(core): default the event type in the event stream decoder Move the SSE default event type from the fetch reader into `decodeEventStreamMessage`, so every consumer of the core decoder gets it. An unnamed message (no `event:` field, or an empty one) that carries data now decodes with `event: 'message'`, as the spec requires. An unnamed message without data (a comment, a keep-alive, or an `id`/`retry`-only message) is never dispatched per spec, so it decodes with no event type. Named messages keep their type with or without data, which the library's own `event: message` and `event: close` messages without data rely on. The fetch reader goes back to its original `switch`: unnamed messages with data now arrive as `message`, and data-less ones still match no case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NVk6KTeqLJfBMM1aWsSHZq --- .../core/src/event-stream/decoder.test.ts | 42 +++++++++++++------ packages/core/src/event-stream/decoder.ts | 15 +++++++ .../core/src/event-stream/encoder.test.ts | 8 +++- packages/fetch/src/event-stream.ts | 8 +--- 4 files changed, 51 insertions(+), 22 deletions(-) diff --git a/packages/core/src/event-stream/decoder.test.ts b/packages/core/src/event-stream/decoder.test.ts index 6369a1e9..84896d11 100644 --- a/packages/core/src/event-stream/decoder.test.ts +++ b/packages/core/src/event-stream/decoder.test.ts @@ -62,6 +62,7 @@ describe('decodeEventStreamMessage', () => { // Per spec, only a single U+0020 SPACE is stripped — not tabs. expect(decodeEventStreamMessage('data:\thello\n\n')).toEqual({ + event: 'message', data: '\thello', }) }) @@ -76,10 +77,25 @@ describe('decodeEventStreamMessage', () => { }) it('treats lines without a colon as fields with empty values', () => { - expect(decodeEventStreamMessage('data\n\n')).toEqual({ data: '' }) - expect(decodeEventStreamMessage('data:\n\n')).toEqual({ data: '' }) - expect(decodeEventStreamMessage('data: a\ndata:\ndata: b\n\n')).toEqual({ data: 'a\n\nb' }) - expect(decodeEventStreamMessage('event\ndata: x\n\n')).toEqual({ event: '', data: 'x' }) + expect(decodeEventStreamMessage('data\n\n')).toEqual({ event: 'message', data: '' }) + expect(decodeEventStreamMessage('data:\n\n')).toEqual({ event: 'message', data: '' }) + expect(decodeEventStreamMessage('data: a\ndata:\ndata: b\n\n')).toEqual({ event: 'message', data: 'a\n\nb' }) + expect(decodeEventStreamMessage('event\ndata: x\n\n')).toEqual({ event: 'message', data: 'x' }) + }) + + it('defaults the event type of an unnamed message with data to message', () => { + expect(decodeEventStreamMessage('data: x\n\n')).toStrictEqual({ event: 'message', data: 'x' }) + expect(decodeEventStreamMessage('event:\ndata: x\n\n')).toStrictEqual({ event: 'message', data: 'x' }) + expect(decodeEventStreamMessage('event: a\nevent:\ndata: x\n\n')).toStrictEqual({ event: 'message', data: 'x' }) + + // named messages keep their event type, with or without data + expect(decodeEventStreamMessage('event: close\n\n')).toStrictEqual({ event: 'close' }) + expect(decodeEventStreamMessage('event: message\n\n')).toStrictEqual({ event: 'message' }) + + // unnamed messages without data are never dispatched, so they get no event type + expect(decodeEventStreamMessage(': ping\n\n')).toStrictEqual({ comments: ['ping'] }) + expect(decodeEventStreamMessage('id: 1\nretry: 10\n\n')).toStrictEqual({ id: '1', retry: 10 }) + expect(decodeEventStreamMessage('event:\nid: 1\n\n')).toStrictEqual({ id: '1' }) }) it('ignores unknown and case-mismatched keys', () => { @@ -161,7 +177,7 @@ describe('eventStreamDecoder', () => { it('ignores empty chunks', () => { expect(feedAll(['', 'data: hello', '', '\n\n', ''])).toEqual([ - { data: 'hello' }, + { event: 'message', data: 'hello' }, ]) }) @@ -221,8 +237,8 @@ describe('eventStreamDecoder', () => { const events = feedAll([stream.slice(0, split), stream.slice(split)]) expect(events, `delimiter ${JSON.stringify(delimiter)} split at ${split}`).toEqual([ - { data: 'first' }, - { data: 'second' }, + { event: 'message', data: 'first' }, + { event: 'message', data: 'second' }, ]) } } @@ -265,8 +281,8 @@ describe('eventStreamDecoder', () => { ]) expect(events).toEqual([ - { data: 'first' }, - { data: 'second' }, + { event: 'message', data: 'first' }, + { event: 'message', data: 'second' }, ]) }) @@ -279,8 +295,8 @@ describe('eventStreamDecoder', () => { ]) expect(events).toEqual([ - { data: 'first' }, - { data: 'second' }, + { event: 'message', data: 'first' }, + { event: 'message', data: 'second' }, ]) }) }) @@ -318,7 +334,7 @@ describe('eventStreamDecoder', () => { expect(() => decoder.end()).toThrowError('Event Stream ended before complete') expect(events).toEqual([ - { data: 'hello' }, + { event: 'message', data: 'hello' }, ]) }) }) @@ -390,7 +406,7 @@ describe('eventStreamDecoderStream', () => { const module = await import('./decoder') expect(module.EventStreamDecoderStream).toBeDefined() - expect(module.decodeEventStreamMessage('data: hello\n\n')).toEqual({ data: 'hello' }) + expect(module.decodeEventStreamMessage('data: hello\n\n')).toEqual({ event: 'message', data: 'hello' }) }) it('on incomplete message', async () => { diff --git a/packages/core/src/event-stream/decoder.ts b/packages/core/src/event-stream/decoder.ts index 1ac09d48..cb50c782 100644 --- a/packages/core/src/event-stream/decoder.ts +++ b/packages/core/src/event-stream/decoder.ts @@ -72,6 +72,21 @@ export function decodeEventStreamMessage(encoded: string): EventStreamMessage { } } + /** + * Per spec, a message with no (or an empty) event type is a 'message' event. + * An unnamed message without data (e.g. a comment-only keep-alive) is never + * dispatched, so it is left without an event type. + * https://html.spec.whatwg.org/multipage/server-sent-events.html#dispatchMessage + */ + if (!message.event) { + if (message.data === undefined) { + delete message.event + } + else { + message.event = 'message' + } + } + return message } diff --git a/packages/core/src/event-stream/encoder.test.ts b/packages/core/src/event-stream/encoder.test.ts index b9f6bdb6..41807adb 100644 --- a/packages/core/src/event-stream/encoder.test.ts +++ b/packages/core/src/event-stream/encoder.test.ts @@ -145,9 +145,13 @@ describe('encodeEventStreamMessage', () => { it('round-trips through decodeEventStreamMessage', () => { const messages: EventStreamMessage[] = [ {}, - { data: 'hello' }, - { data: 'hello\nworld\n' }, + { comments: ['ping'] }, + { event: 'message' }, + { event: 'close' }, + { event: 'message', data: 'hello' }, + { event: 'message', data: 'hello\nworld\n' }, { event: 'message', data: 'hello', id: '123', retry: 10000, comments: ['hi'] }, + { event: 'close', data: 'bye', id: '123' }, ] for (const message of messages) { diff --git a/packages/fetch/src/event-stream.ts b/packages/fetch/src/event-stream.ts index 46d48789..0b34cc22 100644 --- a/packages/fetch/src/event-stream.ts +++ b/packages/fetch/src/event-stream.ts @@ -27,13 +27,7 @@ export function toAsyncIteratorObject( return { done: true, value: undefined } } - /** - * Per the SSE spec, a message with no (or an empty) event type is a 'message' event, - * but an unnamed message without data (e.g. a comment-only keep-alive) is not dispatched. - */ - const event = value.event || (value.data === undefined ? undefined : 'message') - - switch (event) { + switch (value.event) { case 'message': { let message = parseEmptyableJSON(value.data) From af91cceee5930613a9eed660d887e8bd0762adc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:28:06 +0000 Subject: [PATCH 3/5] feat(core): omit the default message event type when encoding `encodeEventStreamMessage` no longer writes `event: message` for a message with data, since `message` is the SSE default event type there. The decoder fills it back in, so `decode(encode(m))` still round-trips. A message without data keeps `event: message`: an unnamed message without data would look like a comment or keep-alive and not be dispatched. This matches the peer adapter, which already sends data messages without an event type. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NVk6KTeqLJfBMM1aWsSHZq --- packages/aws-lambda/src/response.test.ts | 2 +- .../core/src/event-stream/encoder.test.ts | 19 +++++++--- packages/core/src/event-stream/encoder.ts | 6 +++- packages/core/src/event-stream/types.ts | 3 ++ packages/fastify/src/response.test.ts | 2 +- packages/fetch/src/body.test.ts | 2 +- packages/fetch/src/event-stream.test.ts | 36 +++++++++---------- packages/node/src/body.test.ts | 2 +- packages/node/src/event-stream.test.ts | 6 ++-- packages/node/src/response.test.ts | 10 +++--- 10 files changed, 53 insertions(+), 35 deletions(-) diff --git a/packages/aws-lambda/src/response.test.ts b/packages/aws-lambda/src/response.test.ts index b374b2c3..a18efcd1 100644 --- a/packages/aws-lambda/src/response.test.ts +++ b/packages/aws-lambda/src/response.test.ts @@ -132,7 +132,7 @@ describe('sendStandardResponse', () => { }, }) - expect(bodyOf(responseStream)).toBe(': \n\nevent: message\ndata: "foo"\n\nevent: message\ndata: "bar"\n\nevent: close\ndata: "baz"\n\n') + expect(bodyOf(responseStream)).toBe(': \n\ndata: "foo"\n\ndata: "bar"\n\nevent: close\ndata: "baz"\n\n') expect(responseStream.writableEnded).toBe(true) }) diff --git a/packages/core/src/event-stream/encoder.test.ts b/packages/core/src/event-stream/encoder.test.ts index 41807adb..c140defc 100644 --- a/packages/core/src/event-stream/encoder.test.ts +++ b/packages/core/src/event-stream/encoder.test.ts @@ -132,14 +132,25 @@ describe('encodeEventStreamMessage', () => { }) it('encodes fields in order: comments, event, retry, id, data', () => { - expect(encodeEventStreamMessage({ event: 'message', data: 'hello\nworld' })) - .toBe('event: message\ndata: hello\ndata: world\n\n') + expect(encodeEventStreamMessage({ event: 'close', data: 'hello\nworld' })) + .toBe('event: close\ndata: hello\ndata: world\n\n') expect(encodeEventStreamMessage({ event: 'message', id: '123', retry: 10000 })) .toBe('event: message\nretry: 10000\nid: 123\n\n') - expect(encodeEventStreamMessage({ event: 'message', id: '123', retry: 10000, comments: ['hello', 'world'] })) - .toBe(': hello\n: world\nevent: message\nretry: 10000\nid: 123\n\n') + expect(encodeEventStreamMessage({ event: 'close', id: '123', retry: 10000, data: 'hello', comments: ['hello', 'world'] })) + .toBe(': hello\n: world\nevent: close\nretry: 10000\nid: 123\ndata: hello\n\n') + }) + + it('omits the default message event type when the message has data', () => { + expect(encodeEventStreamMessage({ event: 'message', data: 'hello' })).toBe('data: hello\n\n') + expect(encodeEventStreamMessage({ event: 'message', data: '' })).toBe('data: \n\n') + expect(encodeEventStreamMessage({ event: 'message', id: '123', data: 'hello', comments: ['hi'] })) + .toBe(': hi\nid: 123\ndata: hello\n\n') + + // without data the event type is kept, so the message is not mistaken for a comment + expect(encodeEventStreamMessage({ event: 'message' })).toBe('event: message\n\n') + expect(encodeEventStreamMessage({ event: 'message', comments: ['hi'] })).toBe(': hi\nevent: message\n\n') }) it('round-trips through decodeEventStreamMessage', () => { diff --git a/packages/core/src/event-stream/encoder.ts b/packages/core/src/event-stream/encoder.ts index 3a818131..ea0bb0a7 100644 --- a/packages/core/src/event-stream/encoder.ts +++ b/packages/core/src/event-stream/encoder.ts @@ -72,7 +72,11 @@ export function encodeEventStreamMessage(message: EventStreamMessage): string { output += encodeEventStreamMessageComments(message.comments) - if (message.event !== undefined) { + /** + * 'message' is the default event type of a message with data, so it is omitted there. + * A message without data keeps it, so it is not mistaken for a comment or keep-alive. + */ + if (message.event !== undefined && (message.event !== 'message' || message.data === undefined)) { assertEventStreamMessageName(message.event) output += `event: ${message.event}\n` diff --git a/packages/core/src/event-stream/types.ts b/packages/core/src/event-stream/types.ts index 94298c10..807f4df4 100644 --- a/packages/core/src/event-stream/types.ts +++ b/packages/core/src/event-stream/types.ts @@ -22,6 +22,9 @@ export interface EventMeta { export interface EventStreamMessage extends EventMeta { /** * Event name (e.g., `message`, `error`). + * + * `message` is the default for a message with data: the encoder omits it there, + * and the decoder fills it in. */ event?: string | undefined diff --git a/packages/fastify/src/response.test.ts b/packages/fastify/src/response.test.ts index 12b4e12f..b91413da 100644 --- a/packages/fastify/src/response.test.ts +++ b/packages/fastify/src/response.test.ts @@ -201,7 +201,7 @@ describe('sendStandardResponse', () => { 'x-custom-header': 'custom-value', }) - expect(res.text).toEqual(': \n\nevent: message\ndata: "foo"\n\nevent: message\ndata: "bar"\n\nevent: close\ndata: "baz"\n\n') + expect(res.text).toEqual(': \n\ndata: "foo"\n\ndata: "bar"\n\nevent: close\ndata: "baz"\n\n') }) it('chunked (octet)', async ({ onTestFinished }) => { diff --git a/packages/fetch/src/body.test.ts b/packages/fetch/src/body.test.ts index 7ce22181..29c25e99 100644 --- a/packages/fetch/src/body.test.ts +++ b/packages/fetch/src/body.test.ts @@ -423,7 +423,7 @@ describe('toFetchBody', () => { const reader = (body as ReadableStream).pipeThrough(new TextDecoderStream()).getReader() expect(await reader.read()).toEqual({ done: false, value: ': \n\n' }) - expect(await reader.read()).toEqual({ done: false, value: 'event: message\ndata: 123\n\n' }) + expect(await reader.read()).toEqual({ done: false, value: 'data: 123\n\n' }) expect(await reader.read()).toEqual({ done: false, value: 'event: close\ndata: 456\n\n' }) }) diff --git a/packages/fetch/src/event-stream.test.ts b/packages/fetch/src/event-stream.test.ts index 2c281bd3..2d60ae02 100644 --- a/packages/fetch/src/event-stream.test.ts +++ b/packages/fetch/src/event-stream.test.ts @@ -266,8 +266,8 @@ describe('toEventStream', () => { .getReader() expect((await reader.read())).toEqual({ done: false, value: ': \n\n' }) - expect((await reader.read())).toEqual({ done: false, value: 'event: message\nid: id-1\ndata: {"order":1}\n\n' }) - expect((await reader.read())).toEqual({ done: false, value: 'event: message\nretry: 20000\ndata: {"order":2}\n\n' }) + expect((await reader.read())).toEqual({ done: false, value: 'id: id-1\ndata: {"order":1}\n\n' }) + expect((await reader.read())).toEqual({ done: false, value: 'retry: 20000\ndata: {"order":2}\n\n' }) expect((await reader.read())).toEqual({ done: false, value: 'event: message\n\n' }) expect((await reader.read())).toEqual({ done: false, value: 'event: close\nretry: 40000\nid: id-4\ndata: {"order":4}\n\n' }) expect((await reader.read())).toEqual({ done: true }) @@ -285,8 +285,8 @@ describe('toEventStream', () => { .getReader() expect((await reader.read())).toEqual({ done: false, value: ': \n\n' }) - expect((await reader.read())).toEqual({ done: false, value: 'event: message\nid: id-1\ndata: {"order":1}\n\n' }) - expect((await reader.read())).toEqual({ done: false, value: 'event: message\nretry: 20000\ndata: {"order":2}\n\n' }) + expect((await reader.read())).toEqual({ done: false, value: 'id: id-1\ndata: {"order":1}\n\n' }) + expect((await reader.read())).toEqual({ done: false, value: 'retry: 20000\ndata: {"order":2}\n\n' }) expect((await reader.read())).toEqual({ done: false, value: 'event: message\n\n' }) expect((await reader.read())).toEqual({ done: false, value: 'event: close\n\n' }) expect((await reader.read())).toEqual({ done: true }) @@ -305,8 +305,8 @@ describe('toEventStream', () => { .getReader() expect((await reader.read())).toEqual({ done: false, value: ': \n\n' }) - expect((await reader.read()).value).toEqual('event: message\nid: id-1\ndata: {"order":1}\n\n') - expect((await reader.read()).value).toEqual('event: message\nretry: 20000\ndata: {"order":2}\n\n') + expect((await reader.read()).value).toEqual('id: id-1\ndata: {"order":1}\n\n') + expect((await reader.read()).value).toEqual('retry: 20000\ndata: {"order":2}\n\n') expect((await reader.read()).value).toEqual('event: message\n\n') await expect(reader.read()).rejects.toThrow('order-4') }) @@ -324,8 +324,8 @@ describe('toEventStream', () => { .getReader() expect((await reader.read())).toEqual({ done: false, value: ': \n\n' }) - expect((await reader.read()).value).toEqual('event: message\nid: id-1\ndata: {"order":1}\n\n') - expect((await reader.read()).value).toEqual('event: message\nretry: 20000\ndata: {"order":2}\n\n') + expect((await reader.read()).value).toEqual('id: id-1\ndata: {"order":1}\n\n') + expect((await reader.read()).value).toEqual('retry: 20000\ndata: {"order":2}\n\n') expect((await reader.read()).value).toEqual('event: message\n\n') expect((await reader.read()).value).toEqual('event: error\nretry: 40000\nid: id-4\ndata: {"order":4}\n\n') expect((await reader.read()).done).toEqual(true) @@ -514,7 +514,7 @@ describe('toEventStream', () => { ]) await Promise.all([ - expect(reader.read()).resolves.toEqual({ done: false, value: 'event: message\ndata: "hello"\n\n' }), + expect(reader.read()).resolves.toEqual({ done: false, value: 'data: "hello"\n\n' }), vi.advanceTimersByTimeAsync(20), ]) @@ -529,7 +529,7 @@ describe('toEventStream', () => { ]) await Promise.all([ - expect(reader.read()).resolves.toEqual({ done: false, value: 'event: message\ndata: "hello"\n\n' }), + expect(reader.read()).resolves.toEqual({ done: false, value: 'data: "hello"\n\n' }), vi.advanceTimersByTimeAsync(20), ]) @@ -555,12 +555,12 @@ describe('toEventStream', () => { .getReader() await Promise.all([ - expect(reader.read()).resolves.toEqual({ done: false, value: 'event: message\ndata: "hello1"\n\n' }), + expect(reader.read()).resolves.toEqual({ done: false, value: 'data: "hello1"\n\n' }), vi.advanceTimersByTimeAsync(100), ]) await Promise.all([ - expect(reader.read()).resolves.toEqual({ done: false, value: 'event: message\ndata: "hello2"\n\n' }), + expect(reader.read()).resolves.toEqual({ done: false, value: 'data: "hello2"\n\n' }), vi.advanceTimersByTimeAsync(100), ]) @@ -589,7 +589,7 @@ describe('toEventStream', () => { await expect(reader.read()).resolves.toEqual({ done: false, value: ': stream-started\n\n' }) await Promise.all([ - expect(reader.read()).resolves.toEqual({ done: false, value: 'event: message\ndata: "hello"\n\n' }), + expect(reader.read()).resolves.toEqual({ done: false, value: 'data: "hello"\n\n' }), vi.advanceTimersByTimeAsync(50), ]) @@ -613,7 +613,7 @@ describe('toEventStream', () => { .getReader() await Promise.all([ - expect(reader.read()).resolves.toEqual({ done: false, value: 'event: message\ndata: "hello"\n\n' }), + expect(reader.read()).resolves.toEqual({ done: false, value: 'data: "hello"\n\n' }), vi.advanceTimersByTimeAsync(50), ]) @@ -637,7 +637,7 @@ describe('toEventStream', () => { .pipeThrough(new TextDecoderStream()) .getReader() - await expect(reader.read()).resolves.toEqual({ done: false, value: 'event: message\ndata: "hello"\n\n' }) + await expect(reader.read()).resolves.toEqual({ done: false, value: 'data: "hello"\n\n' }) await expect(reader.read()).resolves.toEqual({ done: false, value: 'event: close\n\n' }) await expect(reader.read()).resolves.toEqual({ done: true }) }) @@ -657,7 +657,7 @@ describe('toEventStream', () => { .pipeThrough(new TextDecoderStream()) .getReader() - await expect(reader.read()).resolves.toEqual({ done: false, value: 'event: message\ndata: "hello"\n\n' }) + await expect(reader.read()).resolves.toEqual({ done: false, value: 'data: "hello"\n\n' }) await expect(reader.read()).resolves.toEqual({ done: true }) }) @@ -676,7 +676,7 @@ describe('toEventStream', () => { .pipeThrough(new TextDecoderStream()) .getReader() - await expect(reader.read()).resolves.toEqual({ done: false, value: 'event: message\ndata: "hello"\n\n' }) + await expect(reader.read()).resolves.toEqual({ done: false, value: 'data: "hello"\n\n' }) await expect(reader.read()).resolves.toEqual({ done: false, value: 'event: close\n\n' }) await expect(reader.read()).resolves.toEqual({ done: true }) }) @@ -697,7 +697,7 @@ describe('toEventStream', () => { .pipeThrough(new TextDecoderStream()) .getReader() - await expect(reader.read()).resolves.toEqual({ done: false, value: 'event: message\ndata: "hello"\n\n' }) + await expect(reader.read()).resolves.toEqual({ done: false, value: 'data: "hello"\n\n' }) await expect(reader.read()).resolves.toEqual({ done: false, value: 'event: close\ndata: "bye"\n\n' }) await expect(reader.read()).resolves.toEqual({ done: true }) }) diff --git a/packages/node/src/body.test.ts b/packages/node/src/body.test.ts index 19b08949..aa684580 100644 --- a/packages/node/src/body.test.ts +++ b/packages/node/src/body.test.ts @@ -613,7 +613,7 @@ describe('toNodeHttpBody', () => { const reader = Readable.toWeb((body as Readable)).pipeThrough(new TextDecoderStream()).getReader() expect(await reader.read()).toEqual({ done: false, value: ': \n\n' }) - expect(await reader.read()).toEqual({ done: false, value: 'event: message\ndata: 123\n\n' }) + expect(await reader.read()).toEqual({ done: false, value: 'data: 123\n\n' }) expect(await reader.read()).toEqual({ done: false, value: 'event: close\ndata: 456\n\n' }) expect(await reader.read()).toEqual({ done: true }) }) diff --git a/packages/node/src/event-stream.test.ts b/packages/node/src/event-stream.test.ts index 2c83eb57..477a7280 100644 --- a/packages/node/src/event-stream.test.ts +++ b/packages/node/src/event-stream.test.ts @@ -47,9 +47,9 @@ it('toEventStream', async () => { .getReader() expect((await reader.read())).toEqual({ done: false, value: ': \n\n' }) - expect((await reader.read())).toEqual({ done: false, value: 'event: message\ndata: 1\n\n' }) - expect((await reader.read())).toEqual({ done: false, value: 'event: message\ndata: 2\n\n' }) - expect((await reader.read())).toEqual({ done: false, value: 'event: message\ndata: 3\n\n' }) + expect((await reader.read())).toEqual({ done: false, value: 'data: 1\n\n' }) + expect((await reader.read())).toEqual({ done: false, value: 'data: 2\n\n' }) + expect((await reader.read())).toEqual({ done: false, value: 'data: 3\n\n' }) expect((await reader.read())).toEqual({ done: false, value: 'event: close\n\n' }) expect((await reader.read())).toEqual({ done: true, value: undefined }) diff --git a/packages/node/src/response.test.ts b/packages/node/src/response.test.ts index 2801b6eb..31fa41ee 100644 --- a/packages/node/src/response.test.ts +++ b/packages/node/src/response.test.ts @@ -152,7 +152,7 @@ describe('sendStandardResponse', () => { 'x-custom-header': 'custom-value', }) - expect(res.text).toEqual(': \n\nevent: message\ndata: "foo"\n\nevent: message\ndata: "bar"\n\nevent: close\ndata: "baz"\n\n') + expect(res.text).toEqual(': \n\ndata: "foo"\n\ndata: "bar"\n\nevent: close\ndata: "baz"\n\n') }) it('chunked (octet)', async () => { @@ -342,8 +342,8 @@ describe('sendStandardResponse', () => { expect(chunks).toEqual([ Buffer.from(': \n\n'), - Buffer.from('event: message\ndata: 1\n\n'), - Buffer.from('event: message\ndata: 2\n\n'), + Buffer.from('data: 1\n\n'), + Buffer.from('data: 2\n\n'), ]) expect(responseStream.closed).toBe(false) @@ -399,8 +399,8 @@ describe('sendStandardResponse', () => { expect(chunks).toEqual([ Buffer.from(': \n\n'), - Buffer.from('event: message\ndata: 1\n\n'), - Buffer.from('event: message\ndata: 2\n\n'), + Buffer.from('data: 1\n\n'), + Buffer.from('data: 2\n\n'), ]) expect(responseStream.closed).toBe(false) From 2aa4e332ecfb940985654986f90bf25f827965d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:01:45 +0000 Subject: [PATCH 4/5] docs(core): note that an empty data line still counts as data Clarify in the decoder that an empty `data:` line dispatches with data `''`. Per spec, each `data:` line appends its value plus a line feed, so only a message with no `data:` lines at all has an empty data buffer and is skipped. Add decoder tests for the empty `data:` line, with and without an empty `event:` field. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NVk6KTeqLJfBMM1aWsSHZq --- packages/core/src/event-stream/decoder.test.ts | 4 ++++ packages/core/src/event-stream/decoder.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/core/src/event-stream/decoder.test.ts b/packages/core/src/event-stream/decoder.test.ts index 84896d11..a460a013 100644 --- a/packages/core/src/event-stream/decoder.test.ts +++ b/packages/core/src/event-stream/decoder.test.ts @@ -88,6 +88,10 @@ describe('decodeEventStreamMessage', () => { expect(decodeEventStreamMessage('event:\ndata: x\n\n')).toStrictEqual({ event: 'message', data: 'x' }) expect(decodeEventStreamMessage('event: a\nevent:\ndata: x\n\n')).toStrictEqual({ event: 'message', data: 'x' }) + // an empty data line still counts as data + expect(decodeEventStreamMessage('data:\n\n')).toStrictEqual({ event: 'message', data: '' }) + expect(decodeEventStreamMessage('event:\ndata:\n\n')).toStrictEqual({ event: 'message', data: '' }) + // named messages keep their event type, with or without data expect(decodeEventStreamMessage('event: close\n\n')).toStrictEqual({ event: 'close' }) expect(decodeEventStreamMessage('event: message\n\n')).toStrictEqual({ event: 'message' }) diff --git a/packages/core/src/event-stream/decoder.ts b/packages/core/src/event-stream/decoder.ts index cb50c782..626d9dff 100644 --- a/packages/core/src/event-stream/decoder.ts +++ b/packages/core/src/event-stream/decoder.ts @@ -75,7 +75,8 @@ export function decodeEventStreamMessage(encoded: string): EventStreamMessage { /** * Per spec, a message with no (or an empty) event type is a 'message' event. * An unnamed message without data (e.g. a comment-only keep-alive) is never - * dispatched, so it is left without an event type. + * dispatched, so it is left without an event type. An empty `data:` line still + * counts as data: it dispatches with data `''`. * https://html.spec.whatwg.org/multipage/server-sent-events.html#dispatchMessage */ if (!message.event) { From 96a2ae879412da6448ebdcfe34639fbd3a38f9e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:03:06 +0000 Subject: [PATCH 5/5] docs(core): shorten event stream comments Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NVk6KTeqLJfBMM1aWsSHZq --- packages/core/src/event-stream/decoder.test.ts | 4 ++-- packages/core/src/event-stream/decoder.ts | 10 +++------- packages/core/src/event-stream/encoder.test.ts | 2 +- packages/core/src/event-stream/encoder.ts | 6 ++---- packages/core/src/event-stream/types.ts | 5 +---- 5 files changed, 9 insertions(+), 18 deletions(-) diff --git a/packages/core/src/event-stream/decoder.test.ts b/packages/core/src/event-stream/decoder.test.ts index a460a013..a7945b90 100644 --- a/packages/core/src/event-stream/decoder.test.ts +++ b/packages/core/src/event-stream/decoder.test.ts @@ -92,11 +92,11 @@ describe('decodeEventStreamMessage', () => { expect(decodeEventStreamMessage('data:\n\n')).toStrictEqual({ event: 'message', data: '' }) expect(decodeEventStreamMessage('event:\ndata:\n\n')).toStrictEqual({ event: 'message', data: '' }) - // named messages keep their event type, with or without data + // named messages keep their event type, even without data expect(decodeEventStreamMessage('event: close\n\n')).toStrictEqual({ event: 'close' }) expect(decodeEventStreamMessage('event: message\n\n')).toStrictEqual({ event: 'message' }) - // unnamed messages without data are never dispatched, so they get no event type + // unnamed messages without data get no event type expect(decodeEventStreamMessage(': ping\n\n')).toStrictEqual({ comments: ['ping'] }) expect(decodeEventStreamMessage('id: 1\nretry: 10\n\n')).toStrictEqual({ id: '1', retry: 10 }) expect(decodeEventStreamMessage('event:\nid: 1\n\n')).toStrictEqual({ id: '1' }) diff --git a/packages/core/src/event-stream/decoder.ts b/packages/core/src/event-stream/decoder.ts index 626d9dff..70175e25 100644 --- a/packages/core/src/event-stream/decoder.ts +++ b/packages/core/src/event-stream/decoder.ts @@ -72,13 +72,9 @@ export function decodeEventStreamMessage(encoded: string): EventStreamMessage { } } - /** - * Per spec, a message with no (or an empty) event type is a 'message' event. - * An unnamed message without data (e.g. a comment-only keep-alive) is never - * dispatched, so it is left without an event type. An empty `data:` line still - * counts as data: it dispatches with data `''`. - * https://html.spec.whatwg.org/multipage/server-sent-events.html#dispatchMessage - */ + // Per spec, a message with no (or an empty) event name is a 'message' event if it has + // data, even an empty `data:` line. Without data it is never dispatched. + // https://html.spec.whatwg.org/multipage/server-sent-events.html#dispatchMessage if (!message.event) { if (message.data === undefined) { delete message.event diff --git a/packages/core/src/event-stream/encoder.test.ts b/packages/core/src/event-stream/encoder.test.ts index c140defc..e05addc2 100644 --- a/packages/core/src/event-stream/encoder.test.ts +++ b/packages/core/src/event-stream/encoder.test.ts @@ -148,7 +148,7 @@ describe('encodeEventStreamMessage', () => { expect(encodeEventStreamMessage({ event: 'message', id: '123', data: 'hello', comments: ['hi'] })) .toBe(': hi\nid: 123\ndata: hello\n\n') - // without data the event type is kept, so the message is not mistaken for a comment + // kept without data, so it is not mistaken for a comment expect(encodeEventStreamMessage({ event: 'message' })).toBe('event: message\n\n') expect(encodeEventStreamMessage({ event: 'message', comments: ['hi'] })).toBe(': hi\nevent: message\n\n') }) diff --git a/packages/core/src/event-stream/encoder.ts b/packages/core/src/event-stream/encoder.ts index ea0bb0a7..0b4dc5df 100644 --- a/packages/core/src/event-stream/encoder.ts +++ b/packages/core/src/event-stream/encoder.ts @@ -72,10 +72,8 @@ export function encodeEventStreamMessage(message: EventStreamMessage): string { output += encodeEventStreamMessageComments(message.comments) - /** - * 'message' is the default event type of a message with data, so it is omitted there. - * A message without data keeps it, so it is not mistaken for a comment or keep-alive. - */ + // 'message' is the default for a message with data. Without data, keep it + // so the message is not mistaken for a comment. if (message.event !== undefined && (message.event !== 'message' || message.data === undefined)) { assertEventStreamMessageName(message.event) diff --git a/packages/core/src/event-stream/types.ts b/packages/core/src/event-stream/types.ts index 807f4df4..5dedddfd 100644 --- a/packages/core/src/event-stream/types.ts +++ b/packages/core/src/event-stream/types.ts @@ -21,10 +21,7 @@ export interface EventMeta { export interface EventStreamMessage extends EventMeta { /** - * Event name (e.g., `message`, `error`). - * - * `message` is the default for a message with data: the encoder omits it there, - * and the decoder fills it in. + * Event name (e.g., `message`, `error`). Defaults to `message` for a message with data. */ event?: string | undefined