Repository navigation
feat(fetch)!: throw when an event stream ends without a close event - #169
Conversation
`toAsyncIteratorObject` now only finishes cleanly on a `close` event (or
throws on an `error` event). A stream that ends without either, including
a missing body, rejects with the same `EventStreamDecoderError('Event
Stream ended before complete')` as a stream ending with an incomplete
message, so a dropped connection is no longer mistaken for a finished
stream. Since #125 `toEventStream` always sends a `close` event, so
streams from standard-server senders are unaffected.
This applies to `@standard-server/fetch`, `@standard-server/node` and
`@standard-server/aws-lambda`, which share the fetch implementation.
`@standard-server/peer` already rejects when its queue closes without a
`close` event. Cancelling with `return()` still resolves as done.
## Breaking change
- Reading an event stream that ends without a `close` event now throws
instead of resolving `{ done: true, value: undefined }`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NYpqRELEp4zdwCYWeqRMAj
Drop the cases that only re-test core decoder behavior, keep a plain empty-stream case, and assert the error the same way everywhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NYpqRELEp4zdwCYWeqRMAj
@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 not alter performance
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Event-stream completion now requires an explicit
close/errorevent —toAsyncIteratorObjectthrowsEventStreamDecoderError('Event Stream ended before complete')when the source stream ends without one, instead of silently resolving{ done: true }. This is the sole SSE reader for fetch, node, and aws-lambda, so the change applies across those transports. - Null and empty streams throw on first read — the
reader === undefinedearly return is gone; both a null stream and an immediately-closed stream reject on the firstnext()and resolve{ done: true, value: undefined }on the second, consistent withAsyncIteratorClass's reject-then-done recovery. - Tests updated — fetch/node event-stream fixtures gained explicit
event: closemessages; thewithout close eventcase now asserts reject-then-done; the old null test was renamed towith null streamand a realwith empty streamcase added.
The new branch is consistent with packages/core/README.md:185 (close is treated as end of stream) and with the decoder's existing incomplete-message error. The sender toEventStream always emits event: close on iterator completion, so round-trips and the full suite (1374 tests) pass.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NYpqRELEp4zdwCYWeqRMAj
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Re-reviewed the delta since the prior pullfrog review (f068817).
- README documents the cut-off rule —
packages/core/README.md:185now states that a stream ending without acloseorerrorevent is treated as cut off and reading it throwsEventStreamDecoderError. This matches the implementation inpackages/fetch/src/event-stream.ts:37-39, and the@standard-server/fetch/node/aws-lambdareaders that share it.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
🟢 Approval recommended
The focused change matches the intended breaking contract, preserves cancellation behavior, and has targeted regression coverage.
No open issues 🎉
What changed in this PR
Updates the shared fetch event-stream reader to reject premature termination instead of treating it as successful completion.
Changes:
- Throws
EventStreamDecoderErrorwhen a stream ends without a terminal event, including empty and null streams. - Updates tests to expect rejection or supply an explicit close event.
| File | Description |
|---|---|
| packages/node/src/event-stream.test.ts | Adds a close event to the successful-stream fixture. |
| packages/fetch/src/event-stream.ts | Rejects streams ending without a terminal event. |
| packages/fetch/src/event-stream.test.ts | Covers premature termination and updates successful-stream fixtures. |
Review effort: Balanced

toAsyncIteratorObjectnow only finishes cleanly on acloseevent, or throwsErrorEventon anerrorevent. A stream that ends without either now rejects withEventStreamDecoderError('Event Stream ended before complete'). That is the same error a stream gets when it ends partway through a message. The rule also covers a missing (null) body.Since #125,
toEventStreamalways sends acloseevent, so streams from standard-server senders are unaffected. This change lets the client actually use that signal: a dropped connection no longer looks like a finished stream.Why
When the server's pipeline errors, the client already sees a network error. Many cut-offs still reach the client as a clean end of stream, though, and before this change they read as a finished stream. In a local probe with Node's
fetchandhttp2client:res.end())Proxy timeouts, serverless time limits and graceful shutdowns can end responses in these ways.
Scope
@standard-server/fetch,@standard-server/nodeand@standard-server/aws-lambda, which share the fetch implementation.@standard-server/peeris unchanged. It already rejects when its queue closes before acloseevent.return()still resolves as done.Breaking change
closeevent now throws instead of resolving{ done: true, value: undefined }.event: close: generic SSE APIs, and standard-server senders that set the oldemptyCloseEventEnabled: falseoption.toAsyncIteratorObject(null)now throws on the firstnext(). This happens, for example, with a HEAD or 204 response labelledtext/event-stream.Testing
event: closeor expect the error.pnpm run check(sherif, oxlint, oxfmt, tsc) and the full vitest suite pass locally.🤖 Generated with Claude Code
https://claude.ai/code/session_01NYpqRELEp4zdwCYWeqRMAj