SSE connections now respect middleware headers like CORS#64
Merged
dcrockwell merged 1 commit intodevelopfrom Mar 11, 2026
Merged
SSE connections now respect middleware headers like CORS#64dcrockwell merged 1 commit intodevelopfrom
dcrockwell merged 1 commit intodevelopfrom
Conversation
## Why This Change Was Made - `upgrade_to_sse` was calling `mist.server_sent_events` inside the controller, which sends HTTP headers to the TCP socket immediately — before middleware has a chance to modify the response. This meant CORS headers, security headers, and any other middleware-applied headers were silently discarded, causing cross-origin SSE requests to fail with CORS errors. ## What Was Changed - Added `upgrade_key` constant to `internal.gleam` for a new deferred upgrade stash - Changed `sse.gleam` to stash an upgrade thunk (a closure that will perform the Mist SSE upgrade) instead of calling `mist.server_sent_events` directly - Changed `handler.gleam` to check for the stashed upgrade thunk after `execute_route` returns the middleware-modified Dream response; if found and status is 200, it extracts headers and calls the thunk with them - Added `extract_dream_headers` helper to convert Dream headers to tuples - Added CORS, rejection, and security-headers middleware to the SSE example app - Added 4 new integration test scenarios: CORS headers, middleware stacking, middleware rejection (401 blocks upgrade), and streaming continuity - Added "Using Middleware with SSE" section to the SSE guide - Bumped version to 2.4.1 ## Note to Future Engineer - The `upgrade_key` stash is separate from `response_key` — WebSocket still uses `response_key` in the outer handler function. Don't merge them or you'll break one of the two protocols. Yes, having two different stash-and-upgrade mechanisms for two different protocols in the same handler is a bit of an architectural smell, but Mist's `websocket` doesn't accept an `initial_response` parameter so the WebSocket side can't use this deferred pattern without an upstream change. Enjoy that asymmetry. - The status check (`200 -> upgrade, _ -> convert`) handles the case where middleware calls `next` (which stashes the thunk) but then overrides the response with a non-200. Don't remove it thinking "middleware rejection already prevents the controller from running" — that's only true for middleware that short-circuits before calling `next`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
EventSourcerequests to failWhy
When a controller calls
upgrade_to_sse, it was immediately calling Mist'sserver_sent_events, which sends the HTTP response headers to the TCP socket right away. This happened inside the controller — before middleware had a chance to run on the response. So any middleware that added headers (CORSAccess-Control-Allow-Origin, security headers likeX-Frame-Options, etc.) would modify the Dream response, but those headers were already on the wire without them. The result: cross-origin SSE requests fail silently with CORS errors.What
Core fix (3 source files):
internal.gleam— newupgrade_keyconstant for a deferred upgrade stashsse.gleam—upgrade_to_ssenow stashes an upgrade thunk instead of calling Mist directly; the thunk accepts a list of headers and performs the upgrade with themhandler.gleam— afterexecute_routereturns the middleware-modified response, checks for a stashed upgrade thunk; if found and status is 200, extracts headers from the response and calls the thunkSafety guard: If middleware returns a non-200 status (e.g., 401 from auth), the upgrade thunk is not called and the error response is returned normally.
Integration tests (4 new scenarios):
Docs: Added "Using Middleware with SSE" section to the SSE guide, updated changelog and release notes.
How
The fix uses a deferred upgrade pattern:
upgrade_to_sse→ stashes a closure (the "upgrade thunk") in the process dictionary underupgrade_key, returns a dummy 200 responseinitial_responsewith all the headers and callsmist.server_sent_eventsThis is architecturally similar to the existing WebSocket stash-and-upgrade pattern, but uses a separate stash key because WebSocket's
mist.websocketdoesn't accept aninitial_responseparameter.Test plan