Repository navigation
fix(core): default the SSE event type to message, omit it when encoding - #123
Conversation
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVk6KTeqLJfBMM1aWsSHZq
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVk6KTeqLJfBMM1aWsSHZq
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVk6KTeqLJfBMM1aWsSHZq
@standard-server/aws-lambda
@standard-server/core
@standard-server/fastify
@standard-server/fetch
@standard-server/node
@standard-server/peer
@standard-server/shared
commit: |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will improve performance by 11.11%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | 100 small messages in 1KB chunks |
957.8 µs | 861.4 µs | +11.2% |
| ⚡ | 100 small messages in one chunk |
917.2 µs | 826.1 µs | +11.03% |
Tip
Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.
Comparing claude/tender-mendel-tosiu2 (a269dda) with main (ddd0a09)
Footnotes
-
108 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
There was a problem hiding this comment.
Important
The encoder omission is only safe once the reader ships the matching decoder default. An older @standard-server client has no protocol version negotiation and will silently drop every unnamed data message a new server sends. Please make a deliberate call on the compatibility window before merging.
Reviewed changes
- Encoder omits
event: messagewhen data is present —encodeEventStreamMessage()skips theevent:line when the event type ismessageanddata !== undefined, keeping it for data-less messages so they are not mistaken for comments (packages/core/src/event-stream/encoder.ts:79). - Decoder fills in the default event type —
decodeEventStreamMessage()setsevent: 'message'for any message with data but no (or an empty) event type, and leaves comment/id/retry-only messages without one (packages/core/src/event-stream/decoder.ts:81). - Type documentation —
EventStreamMessage.eventnow documentsmessageas the data-message default (packages/core/src/event-stream/types.ts:26). - Tests — encoder/decoder edge cases and round-trip coverage added; every adapter response/body test moved to the shorter wire form, plus a new
toAsyncIteratorObjecttest for unnameddata:messages (packages/fetch/src/event-stream.test.ts:113).
⚠️ A new server is incompatible with already-published clients
The reader that consumes this wire format is packages/fetch/src/event-stream.ts:30, whose switch (value.event) has no default/undefined case, so it skips any decoded message that lacks a recognized event type. The decoder default added here only exists in the new code; any older @standard-server/core yields { data } with no event for the very messages the encoder now emits unnamed, and the reader loops past them — every message is dropped, not just one. Because the packages have no protocol version handshake, a mixed-version deployment (new server package, older fetch/core client) fails silently rather than loudly.
Technical details
# Backward-compatibility window for the omitted event type
## Affected sites
- `packages/core/src/event-stream/encoder.ts:79` — omits `event: message` for data messages.
- `packages/fetch/src/event-stream.ts:30` — unmodified reader; `switch` on `value.event` with no `case undefined`, so unnamed messages (from an older decoder) are re-read/skipped.
- `packages/core/src/event-stream/decoder.ts:81` — the new defaulting that only newer readers have.
## Required outcome
- A deliberate decision on the compatibility window, since there is no wire-protocol version negotiation. Options: emit `event: message` until readers can be assumed updated; ship the decoder default in a prior/separate release before the encoder omission; or treat the shorter wire form as an explicit breaking change (major bump / release note).
## Open questions for the human
- Is a mixed-version deployment (new server adapter, older `@standard-server/fetch`/`core` reader) a supported scenario? If not, a changelog/release note may be the whole fix.
- Do node/fastify/aws-lambda senders need to stay on the old form for one release cycle?DeepSeek Flash (default — pick a model for stronger reviews) | 𝕏
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVk6KTeqLJfBMM1aWsSHZq
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVk6KTeqLJfBMM1aWsSHZq
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Reviewed the two commits since the prior Pullfrog review at af91cce — 2aa4e33 (docs plus decoder tests for an empty data: line) and 96a2ae87 (comment-only shortening). Neither changes runtime behavior.
- Documented and tested the empty-
data:decode contract —decodeEventStreamMessagenow carries a comment explaining that an unnamed message with data (including an emptydata:line) is amessageevent while a data-less one is left untyped; two new assertions coverdata:\n\nandevent:\ndata:\n\ndecoding to{ event: 'message', data: '' }. Both would fail if the default or the empty-value handling regresses (packages/core/src/event-stream/decoder.ts,decoder.test.ts). - Shortened explanatory comments — the decoder, encoder, types, and test comments were condensed with no semantic change (
packages/core/src/event-stream/{decoder,encoder,types}.ts,*.test.ts).
DeepSeek Flash (default — pick a model for stronger reviews) | 𝕏
…tosiu2 # Conflicts: # packages/fetch/src/event-stream.test.ts

toAsyncIteratorObjectin the fetch adapter, which the node and aws-lambda adapters also use, only handled messages whoseevent:field wasmessage,errororclose. A message with noevent:field was dropped, even though the SSE spec gives it the default typemessage, and many third-party SSE servers send onlydata:lines. The default now lives in the core decoder, and the encoder relies on it to stop writingevent: message.Fixes
event:field, or with an empty one, is now read as amessageevent instead of being skipped. A response from a server that never sendsevent:no longer resolves as an empty stream.Notes for reviewers
decodeEventStreamMessagegives an unnamed message with data the event typemessage. An unnamed message without data (a comment, a keep-alive ping, or anid/retry-only message) is never dispatched per spec, so it decodes with no event type and readers keep skipping it. The fetch reader'sswitchis unchanged frommain.encodeEventStreamMessageno longer writesevent: messagefor a message with data, soyield { a: 1 }sendsdata: {"a":1}. Without data it keeps the line, so the message is not mistaken for a comment. The peer adapter already sends data messages without an event type.closeanderrorwould get through. An old server talking to a new client is fine. The encoder change is its own commit (af91cce), so it can be split out and released later, after clients have the decoder fix.event: message(fromyield undefined) and emptyevent: closedepend on this. Unnamed messages without data must stay skipped, or every keep-alive ping would come through asundefined.Testing
event:, named without data, comment/id/retry-only). New encoder test for omittingevent: message. The round-trip test now covers both.event: message.eslint,tsc -bandvitest run(1244 tests) pass locally. In the Bun suite,data-transferpasses. Fivesignal-and-canceltests fail the same way without this change.🤖 Generated with Claude Code
https://claude.ai/code/session_01NVk6KTeqLJfBMM1aWsSHZq