Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions docs/guides/sse.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions examples/sse/mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
}
43 changes: 43 additions & 0 deletions examples/sse/src/middleware.gleam
Original file line number Diff line number Diff line change
@@ -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
])
}
11 changes: 11 additions & 0 deletions examples/sse/src/router.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
])
}
24 changes: 24 additions & 0 deletions examples/sse/test/integration/features/sse.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion gleam.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
77 changes: 77 additions & 0 deletions releases/release-2.4.1.md
Original file line number Diff line number Diff line change
@@ -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

---
38 changes: 30 additions & 8 deletions src/dream/servers/mist/handler.gleam
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -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()))
Expand Down
2 changes: 2 additions & 0 deletions src/dream/servers/mist/internal.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading