From e05d656b1cd1c44cbf87bd312aea713aa7fe4762 Mon Sep 17 00:00:00 2001 From: Dara Rockwell Date: Tue, 10 Mar 2026 22:11:00 -0600 Subject: [PATCH] fix: preserve middleware-applied headers on SSE responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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`. --- CHANGELOG.md | 6 ++ docs/guides/sse.md | 38 +++++++++ examples/sse/mix.lock | 13 ++++ examples/sse/src/middleware.gleam | 43 +++++++++++ examples/sse/src/router.gleam | 11 +++ .../sse/test/integration/features/sse.feature | 24 ++++++ gleam.toml | 2 +- releases/release-2.4.1.md | 77 +++++++++++++++++++ src/dream/servers/mist/handler.gleam | 38 +++++++-- src/dream/servers/mist/internal.gleam | 2 + src/dream/servers/mist/sse.gleam | 39 +++++++--- 11 files changed, 272 insertions(+), 21 deletions(-) create mode 100644 examples/sse/mix.lock create mode 100644 examples/sse/src/middleware.gleam create mode 100644 releases/release-2.4.1.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e4363b4..4ab7bf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.4.1] - 2026-03-11 + +### Fixed + +- **SSE middleware header preservation** — `upgrade_to_sse` now defers the Mist SSE upgrade until after middleware has run, so CORS, security, and other middleware-applied headers are included in the SSE response. Previously, the upgrade happened inside the controller before middleware could modify the response, causing headers like `Access-Control-Allow-Origin` to be silently discarded. + ## [2.4.0] - 2026-03-10 ### Added diff --git a/docs/guides/sse.md b/docs/guides/sse.md index e9a06d9..22f1161 100644 --- a/docs/guides/sse.md +++ b/docs/guides/sse.md @@ -283,6 +283,44 @@ source.onerror = (event) => { The browser automatically reconnects if the connection drops, sending `Last-Event-ID` so your server can resume from the right point. +## Using Middleware with SSE + +SSE connections work seamlessly with Dream middleware. Any headers added +by middleware (CORS, security headers, authentication, etc.) are included +in the SSE response sent to the client. + +```gleam +import dream/router.{route, router} + +pub fn create_router() { + router() + |> route( + method: Get, + path: "/events", + controller: sse_controller.handle_events, + middleware: [cors_middleware], + ) +} + +fn cors_middleware(request, context, services, next) { + let response = next(request, context, services) + Response( + ..response, + headers: [ + Header("Access-Control-Allow-Origin", "*"), + ..response.headers + ], + ) +} +``` + +The `Access-Control-Allow-Origin` header will be sent to the client as +part of the SSE response, enabling cross-origin `EventSource` connections. + +If middleware returns a non-200 response (for example, a 401 from an +authentication check), the SSE upgrade is not performed and the error +response is returned to the client instead. + ## Testing SSE Apps The `examples/sse` project includes **full integration tests** written diff --git a/examples/sse/mix.lock b/examples/sse/mix.lock new file mode 100644 index 0000000..5152d97 --- /dev/null +++ b/examples/sse/mix.lock @@ -0,0 +1,13 @@ +%{ + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, + "cucumber": {:hex, :cucumber, "0.4.2", "ac171cfbbca1171b2406db9e44c1df85aaa2cbc851710b03fe399b6ba9e11d7d", [:mix], [], "hexpm", "d1fe2ef8ed417e4dbd7bd2670087c848676e799de212f652c84d368ff4dc3485"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, +} diff --git a/examples/sse/src/middleware.gleam b/examples/sse/src/middleware.gleam new file mode 100644 index 0000000..94ccbcb --- /dev/null +++ b/examples/sse/src/middleware.gleam @@ -0,0 +1,43 @@ +import dream/context.{type EmptyContext} +import dream/http/header.{Header} +import dream/http/request.{type Request} +import dream/http/response.{type Response, Response} +import dream/http/status +import services.{type Services} + +pub fn cors( + request: Request, + context: EmptyContext, + services: Services, + next: fn(Request, EmptyContext, Services) -> Response, +) -> Response { + let response = next(request, context, services) + Response(..response, headers: [ + Header("Access-Control-Allow-Origin", "*"), + Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"), + ..response.headers + ]) +} + +pub fn reject_unauthorized( + _request: Request, + _context: EmptyContext, + _services: Services, + _next: fn(Request, EmptyContext, Services) -> Response, +) -> Response { + response.text_response(status.unauthorized, "Forbidden") +} + +pub fn security_headers( + request: Request, + context: EmptyContext, + services: Services, + next: fn(Request, EmptyContext, Services) -> Response, +) -> Response { + let response = next(request, context, services) + Response(..response, headers: [ + Header("X-Content-Type-Options", "nosniff"), + Header("X-Frame-Options", "DENY"), + ..response.headers + ]) +} diff --git a/examples/sse/src/router.gleam b/examples/sse/src/router.gleam index 50aca30..eb56f20 100644 --- a/examples/sse/src/router.gleam +++ b/examples/sse/src/router.gleam @@ -3,6 +3,7 @@ import dream/http/request.{Get} import dream/http/response import dream/http/status import dream/router.{route, router} +import middleware pub fn create() { router() @@ -14,4 +15,14 @@ pub fn create() { ) |> route(Get, "/events", sse_controller.handle_events, []) |> route(Get, "/events/named", sse_controller.handle_named_events, []) + |> route(Get, "/events/cors", sse_controller.handle_events, [ + middleware.cors, + ]) + |> route(Get, "/events/rejected", sse_controller.handle_events, [ + middleware.reject_unauthorized, + ]) + |> route(Get, "/events/stacked", sse_controller.handle_events, [ + middleware.cors, + middleware.security_headers, + ]) } diff --git a/examples/sse/test/integration/features/sse.feature b/examples/sse/test/integration/features/sse.feature index 4459be1..d860ff9 100644 --- a/examples/sse/test/integration/features/sse.feature +++ b/examples/sse/test/integration/features/sse.feature @@ -19,3 +19,27 @@ Feature: Server-Sent Events When I connect to SSE at "/events" And I wait for 10 events Then all 10 events should arrive within 15 seconds + + Scenario: CORS middleware headers appear on SSE response + When I connect to SSE at "/events/cors" + Then the SSE response header "access-control-allow-origin" should contain "*" + And the SSE response header "access-control-allow-methods" should contain "GET, POST, OPTIONS" + And the SSE response header "content-type" should contain "text/event-stream" + And I should receive at least 2 SSE events within 5 seconds + + Scenario: Multiple middleware headers all appear on SSE response + When I connect to SSE at "/events/stacked" + Then the SSE response header "access-control-allow-origin" should contain "*" + And the SSE response header "x-content-type-options" should contain "nosniff" + And the SSE response header "x-frame-options" should contain "DENY" + And I should receive at least 2 SSE events within 5 seconds + + Scenario: Middleware rejection prevents SSE upgrade + When I send a GET request to "/events/rejected" + Then the response status should be 401 + + Scenario: CORS middleware does not interfere with event streaming + When I connect to SSE at "/events/cors" + And I wait for 5 events + Then all 5 events should arrive within 10 seconds + And each event should have a "data" field diff --git a/gleam.toml b/gleam.toml index bd24e2f..3247ed6 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,5 @@ name = "dream" -version = "2.4.0" +version = "2.4.1" description = "Clean, composable web development for Gleam. No magic." licences = ["MIT"] repository = { type = "github", user = "TrustBound", repo = "dream" } diff --git a/releases/release-2.4.1.md b/releases/release-2.4.1.md new file mode 100644 index 0000000..1b14b93 --- /dev/null +++ b/releases/release-2.4.1.md @@ -0,0 +1,77 @@ +# Dream 2.4.1 Release Notes + +**Release Date:** March 11, 2026 + +This release fixes a bug where `upgrade_to_sse` discarded middleware-applied response headers (e.g., CORS), causing cross-origin SSE requests to fail. + +## Key Highlights + +- **SSE middleware header preservation**: CORS, security, and other middleware-applied headers now appear in the SSE response sent to the client +- **Middleware rejection guard**: If middleware returns a non-200 status, the SSE upgrade is not performed and the error response is returned to the client +- **Comprehensive integration tests**: 4 new integration test scenarios covering CORS headers, middleware stacking, rejection, and streaming continuity + +## Fixed + +### SSE upgrade discards middleware-applied response headers + +Mist's `server_sent_events` sends HTTP headers to the TCP socket immediately during the call. Because `upgrade_to_sse` was calling `server_sent_events` inside the controller — before middleware had a chance to modify the response — any headers added by middleware (CORS, security headers, etc.) were silently discarded. + +The fix introduces a **deferred upgrade** pattern: + +1. The controller calls `upgrade_to_sse`, which stashes an **upgrade thunk** (a function that will perform the Mist SSE upgrade) instead of performing the upgrade immediately. +2. The middleware chain runs on the dummy response, adding CORS/security/other headers. +3. The handler checks for the stashed upgrade thunk after middleware completes. If found and the response status is 200, it extracts headers from the middleware-modified Dream response and passes them to the upgrade thunk. +4. The thunk calls `mist.server_sent_events` with an `initial_response` that includes all middleware-applied headers. + +If middleware returns a non-200 status (e.g., 401 from an auth check), the upgrade thunk is not called and the error response is returned normally. + +### Before (broken) + +``` +Controller -> upgrade_to_sse() -> Mist sends bare 200 headers immediately +Middleware -> adds CORS headers to dummy response (too late, already on wire) +Handler -> returns Mist response (CORS headers discarded) +``` + +### After (fixed) + +``` +Controller -> upgrade_to_sse() -> stashes upgrade thunk, returns dummy response +Middleware -> adds CORS headers to dummy response +Handler -> extracts headers from response, calls upgrade thunk with CORS headers +Mist -> sends 200 with CORS headers to client +``` + +## Added + +- `upgrade_key` constant in `internal.gleam` for the deferred upgrade stash +- `extract_dream_headers` helper in `handler.gleam` to convert Dream headers to tuples +- `examples/sse/src/middleware.gleam` with CORS, rejection, and security-headers middleware +- 3 new SSE example routes: `/events/cors`, `/events/rejected`, `/events/stacked` +- 4 new integration test scenarios: + - CORS middleware headers appear on SSE response + - Multiple middleware headers all appear on SSE response + - Middleware rejection prevents SSE upgrade + - CORS middleware does not interfere with event streaming +- "Using Middleware with SSE" section in the SSE guide + +## Upgrading + +Update your dependencies: + +```toml +[dependencies] +dream = ">= 2.4.1 and < 3.0.0" +``` + +Then run: + +```bash +gleam deps download +``` + +## Documentation + +- [dream](https://hexdocs.pm/dream) - v2.4.1 + +--- diff --git a/src/dream/servers/mist/handler.gleam b/src/dream/servers/mist/handler.gleam index fee0273..e2e7106 100644 --- a/src/dream/servers/mist/handler.gleam +++ b/src/dream/servers/mist/handler.gleam @@ -1,7 +1,7 @@ import dream/dream import dream/http/header.{Header} import dream/http/request.{type Request, Request} -import dream/http/response.{Response, Text} +import dream/http/response.{type Response, Response, Text} import dream/router.{type Route, type Router, find_route} import dream/servers/mist/internal import dream/servers/mist/request as mist_request @@ -11,6 +11,7 @@ import gleam/bytes_tree import gleam/erlang/atom import gleam/http/request as http_request import gleam/http/response as http_response +import gleam/list import gleam/option import gleam/result import gleam/yielder @@ -77,11 +78,15 @@ fn create_request_handler( let response_key = atom.create(internal.response_key) internal.put(response_key, internal.to_dynamic(option.None)) - // 3. Create a "Lightweight" Request (Headers, Path, Method only) + // 3. Initialize upgrade stash to None (for deferred SSE upgrades) + let upgrade_key = atom.create(internal.upgrade_key) + internal.put(upgrade_key, internal.to_dynamic(option.None)) + + // 4. Create a "Lightweight" Request (Headers, Path, Method only) let #(partial_request, request_id) = mist_request.convert_metadata(mist_request) - // 4. Find the route using the lightweight request + // 5. Find the route using the lightweight request let dream_response = case find_route(router, partial_request) { option.Some(#(route, params)) -> { // Create context for this request by updating template with request_id @@ -113,7 +118,7 @@ fn create_request_handler( } } - // 5. Check for upgrade response in stash + // 6. Check for upgrade response in stash (WebSocket) let raw_upgrade = internal.get(response_key) let upgrade_result: option.Option(http_response.Response(ResponseData)) = internal.unsafe_coerce(raw_upgrade) @@ -143,8 +148,6 @@ fn handle_routed_request( case final_request_result { Ok(final_request) -> { - // 4. Execute the route directly (we already found it above) - // execute_route will set params on the request internally let dream_response = dream.execute_route( route, @@ -154,8 +157,20 @@ fn handle_routed_request( services_instance, ) - // 5. Convert Dream response back to mist format - mist_response.convert(dream_response) + let upgrade_key = atom.create(internal.upgrade_key) + let raw_upgrade = internal.get(upgrade_key) + let maybe_upgrade: option.Option( + fn(List(#(String, String))) -> http_response.Response(ResponseData), + ) = internal.unsafe_coerce(raw_upgrade) + + case maybe_upgrade { + option.Some(perform_upgrade) -> + case dream_response.status { + 200 -> perform_upgrade(extract_dream_headers(dream_response)) + _ -> mist_response.convert(dream_response) + } + option.None -> mist_response.convert(dream_response) + } } Error(response) -> response } @@ -200,6 +215,13 @@ fn prepare_buffered_request( } } +fn extract_dream_headers(response: Response) -> List(#(String, String)) { + list.map(response.headers, fn(h) { + let Header(name, value) = h + #(name, value) + }) +} + fn bad_request_response() -> http_response.Response(ResponseData) { http_response.new(400) |> http_response.set_body(Bytes(bytes_tree.new())) diff --git a/src/dream/servers/mist/internal.gleam b/src/dream/servers/mist/internal.gleam index 3395609..1422bbf 100644 --- a/src/dream/servers/mist/internal.gleam +++ b/src/dream/servers/mist/internal.gleam @@ -12,6 +12,8 @@ pub const request_key = "dream_mist_request" pub const response_key = "dream_mist_response" +pub const upgrade_key = "dream_mist_upgrade" + @external(erlang, "erlang", "put") pub fn put(key: Atom, value: Dynamic) -> Dynamic diff --git a/src/dream/servers/mist/sse.gleam b/src/dream/servers/mist/sse.gleam index bc74e75..fce3f72 100644 --- a/src/dream/servers/mist/sse.gleam +++ b/src/dream/servers/mist/sse.gleam @@ -25,10 +25,12 @@ //// //// 1. Router sends a request to a controller. //// 2. Controller calls `upgrade_to_sse`. -//// 3. Mist spawns a dedicated OTP actor for this connection. -//// 4. `on_init` runs once, receiving a `Subject(message)` that external +//// 3. Middleware runs on the dummy response (adding CORS, security headers, etc.). +//// 4. The handler performs the actual Mist SSE upgrade, forwarding all +//// middleware-applied headers to the client. +//// 5. `on_init` runs once, receiving a `Subject(message)` that external //// code can use to send messages into the actor. -//// 5. `on_message` runs for each message received by the actor. +//// 6. `on_message` runs for each message received by the actor. //// //// Handlers follow Dream's "no closures" rule: instead of capturing //// dependencies, you define a `Dependencies` type and pass it explicitly @@ -92,10 +94,13 @@ import gleam/erlang/atom import gleam/erlang/process.{type Selector, type Subject} import gleam/http/request as http_request import gleam/http/response as http_response +import gleam/list import gleam/option.{type Option, None, Some} import gleam/otp/actor import gleam/string_tree -import mist.{type SSEConnection as MistSSEConnection} +import mist.{ + type Connection, type ResponseData, type SSEConnection as MistSSEConnection, +} /// An SSE connection handle. /// @@ -129,9 +134,10 @@ pub opaque type Action(state, message) { /// Upgrade an HTTP request to a Server-Sent Events connection. /// -/// This function must be called from within a Dream controller. It spawns -/// a dedicated OTP actor for the SSE connection with its own mailbox, -/// avoiding the stalling issues of chunked transfer encoding. +/// This function must be called from within a Dream controller. It defers +/// the actual Mist SSE upgrade until after middleware has run, so any +/// headers added by middleware (CORS, security, etc.) are included in the +/// SSE response sent to the client. /// /// ## Parameters /// @@ -174,7 +180,7 @@ pub fn upgrade_to_sse( let request_key = atom.create(internal.request_key) let raw_request = internal.get(request_key) - let mist_request: http_request.Request(mist.Connection) = + let mist_request: http_request.Request(Connection) = internal.unsafe_coerce(raw_request) let wrapped_init = fn(subj: Subject(message)) { @@ -197,16 +203,25 @@ pub fn upgrade_to_sse( next } - let mist_response = + let perform_upgrade = fn(headers: List(#(String, String))) { + let initial_response = + list.fold(headers, http_response.new(200), fn(resp, h) { + http_response.set_header(resp, h.0, h.1) + }) mist.server_sent_events( request: mist_request, - initial_response: http_response.new(200), + initial_response: initial_response, init: wrapped_init, loop: wrapped_loop, ) + } + + let upgrade_thunk: Option( + fn(List(#(String, String))) -> http_response.Response(ResponseData), + ) = Some(perform_upgrade) - let response_key = atom.create(internal.response_key) - internal.put(response_key, internal.unsafe_coerce(Some(mist_response))) + let upgrade_key = atom.create(internal.upgrade_key) + internal.put(upgrade_key, internal.unsafe_coerce(upgrade_thunk)) empty_response(200) }