Skip to content

fix(streaming): expose Last-Event-ID and make write-after-abort a no-op - #5289

Open
SynthLuvr wants to merge 2 commits into
honojs:mainfrom
SynthLuvr:fix/sse-lifecycle-3765
Open

fix(streaming): expose Last-Event-ID and make write-after-abort a no-op#5289
SynthLuvr wants to merge 2 commits into
honojs:mainfrom
SynthLuvr:fix/sse-lifecycle-3765

Conversation

@SynthLuvr

Copy link
Copy Markdown

Fixes #3765

What

Two fixes to the SSE streaming lifecycle:

  1. streamSSE now exposes the Last-Event-ID request header as stream.lastEventId. When an EventSource reconnects, the browser re-sends the id of the last event it received in this header, so handlers can resume the event stream instead of replaying everything. It is undefined when the header is absent.

  2. StreamingApi.write() and close() are no-ops after abort(). Once the client is gone the writable may be errored, and touching the writer can throw or hang depending on the runtime. Both methods now return early when aborted is set, so a handler that keeps producing after a disconnect completes cleanly instead of hanging or crashing.

Testing

  • src/utils/stream.test.ts: write()/close() after abort() and after the client cancels the response stream resolve as no-ops without touching the writer.
  • src/helper/streaming/sse.test.tsx: lastEventId is exposed from the header and undefined when absent; writeSSE() after a client disconnect resolves as a no-op and the handler finishes.
  • runtime-tests/node/index.test.ts: end-to-end over a real Node server — resume from Last-Event-ID on reconnect, full replay without the header, and a handler finishing cleanly after a mid-stream disconnect.

Addresses the SSE lifecycle pain points behind honojs#3765, extracted from the
workarounds apps had to build on top of streamSSE:

- Last-Event-ID exposure: the SSE callback could not see the
  `Last-Event-ID` header that EventSource sends automatically on
  reconnect, so resuming a stream required reading
  `c.req.header('last-event-id')` outside `streamSSE` and threading the
  cursor in. `SSEStreamingApi` now carries `lastEventId` (optional third
  constructor argument, populated by `streamSSE`), so handlers can resume
  natively from the last event the client received.

- Write-after-abort semantics: writing after the stream was aborted
  still touched the underlying writer, relying on the runtime to error
  it once the disconnect propagates. On runtimes where cancellation is
  not propagated to the writable (e.g. Bun < 1.1.27, the same versions
  hono already special-cases), a backpressured write after abort never
  settles and the handler hangs forever — the "SSE does not work" class
  of failure. `StreamingApi.write()`/`writeln()` now short-circuit once
  `aborted` is set: writes are defined no-ops that can neither throw nor
  hang, and `close()` after abort no longer touches the torn-down
  writer.
- Condense the write()/close() abort-guard comments to the essential
  runtime-behavior rationale
- Shorten the lastEventId doc to the reconnect semantics
- Reset lifecycle test counters in beforeEach instead of inside the route
- Drop narration comments; simplify SSE frame data extraction in tests
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.

SSE does not work again...

1 participant