Skip to content

feat(sse): invoke finalize on response stream end#2741

Merged
kettanaito merged 12 commits into
mainfrom
fix/sse-finalize
Jul 7, 2026
Merged

feat(sse): invoke finalize on response stream end#2741
kettanaito merged 12 commits into
mainfrom
fix/sse-finalize

Conversation

@kettanaito

@kettanaito kettanaito commented May 7, 2026

Copy link
Copy Markdown
Member

Todos

  • Consider rewriting this to the RequestHandler level, if possible, to support finalize() for user-provided ReadableStream in mocked responses. Otherwise, it will be called immediately when the response is returned and not when the stream is closed.
  • If reliable finalize() support is costly (e.g. requires stream piping), apply it conditionally only when the user referenced the finalize key from the response resolver argument ala Playwright.
  • Double-check emitter being shared across requests. Add a test that has multiple SSE connections at the same time with different closure logic. Ensure their cleanups fire independently.

@coderabbitai

coderabbitai Bot commented May 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: e9a086a1-8fe3-41c4-8232-5df566375f98

📥 Commits

Reviewing files that changed from the base of the PR and between 170959c and 04d1b04.

📒 Files selected for processing (1)
  • src/core/sse.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/core/sse.ts

📝 Walkthrough

Walkthrough

RequestHandler now defers finalize wiring and cleanup timing to response body settlement. SSE handling switches to a controller-backed readable stream with typed events, and event-stream responses are no longer cloned for transfer.

Changes

Finalize and response-stream cleanup

Layer / File(s) Summary
Response body observation
src/core/utils/internal/observe-response-body-stream.ts, src/core/utils/internal/observe-response-body-stream.test.ts, src/core/utils/HttpResponse/decorators.ts
Adds observeResponseBodyStream and copyResponseOwnProperties, with tests covering no-body, used, locked, completion, error, cancellation, and pipeTo cases.
Lazy finalize and stream-aware cleanup
src/core/handlers/RequestHandler.ts, src/core/handlers/HttpHandler.test.ts, test/node/msw-api/finalize.test.ts
run() lazily wires finalize and abort handling; resolver completion routes through complete(), which now distinguishes streamed and non-streamed responses and applies cleanup timing accordingly.
SSE controller-backed client
src/core/sse.ts
SSE client events become typed, and the client/stream pipeline uses a ReadableStreamDefaultController plus cancellation-aware stream creation.
SSE logging, clone suppression, and finalize tests
src/core/sse.ts, src/mockServiceWorker.js, test/browser/sse-api/sse.finalize.test.ts
SSE logging cancels response bodies, event-stream responses skip cloning in the worker, and browser tests verify finalize timing across close, error, parallel, and synchronous-close paths.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • mswjs/msw#2718: Both PRs modify the SSE lifecycle in src/core/sse.ts, including stream creation and connection shutdown behavior.
  • mswjs/msw#2738: Both PRs change RequestHandler.ts cleanup/finalize behavior around resolver completion and abort timing.

Poem

A rabbit rode the event-stream lane,
and counted cleanups after rain.
When bodies settled, done was done,
and typed events hopped one by one. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the SSE finalize timing change and matches the main implementation.
Description check ✅ Passed The description is directly about the SSE finalize follow-up and the issue it fixes.
Linked Issues check ✅ Passed The changes add cleanup/finalize handling for SSE responses and tests for abort, close, error, and returned cleanup callbacks.
Out of Scope Changes check ✅ Passed The changes stay focused on SSE finalize behavior, response stream observation, and related tests without obvious unrelated additions.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sse-finalize

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented May 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/msw@2741

commit: 04d1b04

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
test/browser/sse-api/sse.finally.test.ts (1)

15-127: ⚡ Quick win

Add a multi-connection regression test.

The three scenarios cover happy paths for a single connection but won't catch the per-handler emitter cross-fire concern flagged in src/core/sse.ts (one connection's close running another connection's finalize). A test that opens two EventSource instances against the same handler, closes the first, and asserts that the second connection's finalize has NOT yet been invoked would protect against that regression once it's fixed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/browser/sse-api/sse.finally.test.ts` around lines 15 - 127, Add a new
regression test that opens two EventSource connections to the same sse handler
and verifies closing the first does not trigger the second handler's finalize;
specifically, in the test use setupWorker and sse with a finalize(() =>
window.notifyFinalizedSecond()) for the second connection (and
notifyFinalizedFirst for the first), create two EventSource instances in the
page.evaluate, close the first (or call client.close() in the handler for the
first), and assert that the Promise for the second connection's finalize has not
resolved after the first closes (then finally close the second and assert its
finalize resolves); reference the existing symbols setupWorker, sse, finalize,
client.close and the exposed functions notifyFinalized* to locate where to add
this new test.
src/core/sse.ts (1)

311-337: ⚡ Quick win

Make error() consistent with close() (state ordering + try/catch).

close() swallows controller exceptions and uses #closed.resolve(), but error() does not. If this.#controller.error() throws (e.g., the stream was already cancelled by the reader, putting the controller in a non-pending state on the controller side while #closed is still pending here), #closed never resolves and the 'error' event never gets emitted — both client and any awaiters of #closed become stuck. Mirroring the structure used in close() keeps the two methods symmetric and idempotent.

♻️ Proposed fix
   public error(): void {
     if (this.#closed.state !== 'pending') {
       return
     }

-    this.#controller.error()
-    this.#closed.resolve()
-    this[kClientEmitter]?.emit(new TypedEvent('error'))
+    this.#closed.resolve()
+    try {
+      this.#controller.error()
+    } catch {
+      //
+    }
+    this[kClientEmitter]?.emit(new TypedEvent('error'))
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/sse.ts` around lines 311 - 337, The error() method must be made
symmetric to close(): first check this.#closed.state !== 'pending' and return,
then call this.#controller.error() inside a try/catch (swallowing controller
exceptions), always call this.#closed.resolve() after the controller call (even
if controller.error() threw), and finally emit the 'error' event via
this[kClientEmitter]?.emit(new TypedEvent('error')); modify the error()
implementation in the class containing methods error() and close() to mirror
close()’s ordering and try/catch to ensure idempotence and that `#closed` is
always resolved.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/sse.ts`:
- Around line 200-211: The current exhaustCleanups registers listeners on the
shared `#emitter` which causes cross-connection firing and misses synchronous
closes; change it to use a per-connection close signal instead of `#emitter`:
create a per-connection promise/emitter (or use the existing `#closed` deferred
tied to this client instance) and, inside exhaustCleanups, if that
per-connection closed is already resolved call super.exhaustCleanups(cleanups)
immediately, otherwise attach a single per-connection listener (or await the
per-connection close promise) to invoke super.exhaustCleanups(cleanups) when
that specific connection closes; remove usage of the shared `#emitter` for
per-connection cleanup wiring to avoid cross-fire and race conditions when
resolvers call client.close() synchronously.

In `@src/mockServiceWorker.js`:
- Around line 137-141: The content-type check in the conditional that guards
sendToClient is too strict and fails to detect SSE headers with parameters
(e.g., "text/event-stream; charset=utf-8"), causing responseClone.body to be
transferred and corrupt SSE streams; update the check used where client,
activeClientIds.has(client.id) and response.headers.get('content-type') are
evaluated so it treats any content type that starts with or has the MIME prefix
"text/event-stream" as SSE (e.g., use a startsWith or parse-and-compare on
response.headers.get('content-type')), and ensure sendToClient is not passed
responseClone.body for those SSE responses to avoid detaching the stream
(references: client, activeClientIds, response.headers.get, responseClone.body,
sendToClient, core/sse.ts).

---

Nitpick comments:
In `@src/core/sse.ts`:
- Around line 311-337: The error() method must be made symmetric to close():
first check this.#closed.state !== 'pending' and return, then call
this.#controller.error() inside a try/catch (swallowing controller exceptions),
always call this.#closed.resolve() after the controller call (even if
controller.error() threw), and finally emit the 'error' event via
this[kClientEmitter]?.emit(new TypedEvent('error')); modify the error()
implementation in the class containing methods error() and close() to mirror
close()’s ordering and try/catch to ensure idempotence and that `#closed` is
always resolved.

In `@test/browser/sse-api/sse.finally.test.ts`:
- Around line 15-127: Add a new regression test that opens two EventSource
connections to the same sse handler and verifies closing the first does not
trigger the second handler's finalize; specifically, in the test use setupWorker
and sse with a finalize(() => window.notifyFinalizedSecond()) for the second
connection (and notifyFinalizedFirst for the first), create two EventSource
instances in the page.evaluate, close the first (or call client.close() in the
handler for the first), and assert that the Promise for the second connection's
finalize has not resolved after the first closes (then finally close the second
and assert its finalize resolves); reference the existing symbols setupWorker,
sse, finalize, client.close and the exposed functions notifyFinalized* to locate
where to add this new test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c04fab05-4a10-4231-942a-2f89db1e3242

📥 Commits

Reviewing files that changed from the base of the PR and between 91a5d41 and f5bbf27.

📒 Files selected for processing (4)
  • src/core/handlers/RequestHandler.ts
  • src/core/sse.ts
  • src/mockServiceWorker.js
  • test/browser/sse-api/sse.finally.test.ts

Comment thread src/core/sse.ts Outdated
Comment thread src/mockServiceWorker.js Outdated
@kettanaito
kettanaito marked this pull request as draft May 7, 2026 20:14
@piotr-cz

piotr-cz commented May 8, 2026

Copy link
Copy Markdown
Contributor

Tested and works as expected:

The finalize callback is executed when sse stream is aborted:

  • when app aborts stream
  • with MSW's ServerSentEventClient.close()
  • with MSW's ServerSentEventClient.error()

@kettanaito kettanaito changed the title fix(sse): invoke finalize on response stream end feat(sse): invoke finalize on response stream end May 12, 2026
@kettanaito kettanaito added the BREAKING CHANGE Pull request introducing breaking changes. label May 12, 2026
@piotr-cz

piotr-cz commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

I'm not sure what's going on, but on msw@2.14.4 (where finalize has been added) the finalize callback is being invoked immediately in my sse handler:

sse('/ping', ({ client, finalize }) => {
  finalize(() => {
    console.log('finalize run')
  })
})

@kettanaito
kettanaito marked this pull request as ready for review July 7, 2026 17:09
@kettanaito kettanaito removed the BREAKING CHANGE Pull request introducing breaking changes. label Jul 7, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/core/sse.ts (1)

171-180: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Avoid registering log listeners on the shared SSE emitter
#attachClientLogger() adds new listeners on this.#emitter for every request, so concurrent connections will duplicate message/error/close logs and keep stale publicUrl closures alive. Use per-connection listeners or remove them when the stream ends.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/sse.ts` around lines 171 - 180, The shared SSE emitter in
`#attachClientLogger`() is accumulating per-request listeners, which causes
duplicated message/error/close logs and retains stale publicUrl closures across
connections. Update the logging hookup around
emitter.on('message'/'error'/'close') to use connection-scoped listeners tied to
a specific stream, and ensure they are removed or cleaned up when the SSE
connection ends so each request logs independently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/handlers/RequestHandler.ts`:
- Around line 367-386: `getFinalize()` in `RequestHandler` should handle
requests whose signal is already aborted before the first access to `finalize`;
add the same immediate-abort path used by `complete()` so scheduled cleanups run
right away instead of waiting for resolver settlement. Update the lazy
abort-listener setup around `args.request.signal.addEventListener` to first
check `signal.aborted`, then either call `runScheduledCleanups(args.requestId)`
immediately or register the listener as today, and keep `scheduleCleanup`
behavior unchanged. Add a regression test covering an abort that happens before
the resolver first reads `finalize`.

---

Outside diff comments:
In `@src/core/sse.ts`:
- Around line 171-180: The shared SSE emitter in `#attachClientLogger`() is
accumulating per-request listeners, which causes duplicated message/error/close
logs and retains stale publicUrl closures across connections. Update the logging
hookup around emitter.on('message'/'error'/'close') to use connection-scoped
listeners tied to a specific stream, and ensure they are removed or cleaned up
when the SSE connection ends so each request logs independently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 82a2965e-c6d0-49f0-aa0d-fa9d1e507c7c

📥 Commits

Reviewing files that changed from the base of the PR and between f5bbf27 and a1da3e8.

📒 Files selected for processing (9)
  • src/core/handlers/HttpHandler.test.ts
  • src/core/handlers/RequestHandler.ts
  • src/core/sse.ts
  • src/core/utils/HttpResponse/decorators.ts
  • src/core/utils/internal/observe-response-body-stream.test.ts
  • src/core/utils/internal/observe-response-body-stream.ts
  • src/mockServiceWorker.js
  • test/browser/sse-api/sse.finalize.test.ts
  • test/node/msw-api/finalize.test.ts
✅ Files skipped from review due to trivial changes (1)
  • test/node/msw-api/finalize.test.ts

Comment thread src/core/handlers/RequestHandler.ts
@kettanaito
kettanaito merged commit 7fae0cc into main Jul 7, 2026
23 checks passed
@kettanaito
kettanaito deleted the fix/sse-finalize branch July 7, 2026 19:24
@kettanaito

Copy link
Copy Markdown
Member Author

Released: v2.15.0 🎉

This has been released in v2.15.0.

Get these changes by running the following command:

npm i msw@latest

Predictable release automation by Release.

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.

Support onCleanup callback to schedule side effect cleanup when resolvers are finished

2 participants