Skip to content

feat(fetch)!: throw when an event stream ends without a close event - #169

Merged
dinwwwh merged 3 commits into
mainfrom
claude/serene-wozniak-qotci6
Oct 18, 2026
Merged

dinwwwh merged 3 commits into
mainfrom
claude/serene-wozniak-qotci6

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

toAsyncIteratorObject now only finishes cleanly on a close event, or throws ErrorEvent on an error event. A stream that ends without either now rejects with EventStreamDecoderError('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, toEventStream always sends a close event, 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 fetch and http2 client:

  • an HTTP/1.1 response ended early (res.end())
  • a connection-close-delimited body (an HTTP/1.0 hop) whose connection drops
  • an HTTP/2 stream ended early, or its session torn down

Proxy timeouts, serverless time limits and graceful shutdowns can end responses in these ways.

Scope

  • Applies to @standard-server/fetch, @standard-server/node and @standard-server/aws-lambda, which share the fetch implementation.
  • @standard-server/peer is unchanged. It already rejects when its queue closes before a close event.
  • Stopping early 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 }.
  • This affects SSE senders that never send event: close: generic SSE APIs, and standard-server senders that set the old emptyCloseEventEnabled: false option.
  • toAsyncIteratorObject(null) now throws on the first next(). This happens, for example, with a HEAD or 204 response labelled text/event-stream.

Testing

  • Updated the tests that relied on a close-less stream finishing quietly. They now either send event: close or expect the error.
  • Added an empty-stream case and changed the null-stream case to 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

`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
@pkg-pr-new

pkg-pr-new Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
@standard-server/aws-lambda

npm i https://pkg.pr.new/@standard-server/aws-lambda@169

@standard-server/core

npm i https://pkg.pr.new/@standard-server/core@169

@standard-server/fastify

npm i https://pkg.pr.new/@standard-server/fastify@169

@standard-server/fetch

npm i https://pkg.pr.new/@standard-server/fetch@169

@standard-server/node

npm i https://pkg.pr.new/@standard-server/node@169

@standard-server/peer

npm i https://pkg.pr.new/@standard-server/peer@169

@standard-server/shared

npm i https://pkg.pr.new/@standard-server/shared@169

commit: 07cbb4e

@codecov

codecov Bot commented Oct 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@dinwwwh
dinwwwh requested a balanced review from Copilot October 18, 2026 09:03
@codspeed

codspeed Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

⚠️ 4 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 26 untouched benchmarks
⏩ 108 skipped benchmarks1


Comparing claude/serene-wozniak-qotci6 (07cbb4e) with main (7a4134f)

Open in CodSpeed

Footnotes

  1. 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. ↩

@dinwwwh dinwwwh changed the title Require explicit close event to complete event streams feat(fetch)!: throw when an event stream ends without a close event Oct 18, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Event-stream completion now requires an explicit close/error event — toAsyncIteratorObject throws EventStreamDecoderError('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 === undefined early return is gone; both a null stream and an immediately-closed stream reject on the first next() and resolve { done: true, value: undefined } on the second, consistent with AsyncIteratorClass's reject-then-done recovery.
  • Tests updated — fetch/node event-stream fixtures gained explicit event: close messages; the without close event case now asserts reject-then-done; the old null test was renamed to with null stream and a real with empty stream case 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.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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:185 now states that a stream ending without a close or error event is treated as cut off and reading it throws EventStreamDecoderError. This matches the implementation in packages/fetch/src/event-stream.ts:37-39, and the @standard-server/fetch / node / aws-lambda readers that share it.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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 EventStreamDecoderError when 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

@dinwwwh
dinwwwh merged commit aeebe68 into main Oct 18, 2026
11 checks passed
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.

3 participants