Skip to content

SSE connections now respect middleware headers like CORS#64

Merged
dcrockwell merged 1 commit intodevelopfrom
fix/sse-cors-headers
Mar 11, 2026
Merged

SSE connections now respect middleware headers like CORS#64
dcrockwell merged 1 commit intodevelopfrom
fix/sse-cors-headers

Conversation

@dcrockwell
Copy link
Copy Markdown
Contributor

Summary

  • SSE connections were silently dropping headers added by middleware (CORS, security headers, etc.), causing cross-origin EventSource requests to fail
  • The fix defers the Mist SSE upgrade until after the middleware chain completes, so all middleware-applied headers are included in the SSE response
  • Adds 4 new integration test scenarios and middleware examples to the SSE example app

Why

When a controller calls upgrade_to_sse, it was immediately calling Mist's server_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 (CORS Access-Control-Allow-Origin, security headers like X-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 — new upgrade_key constant for a deferred upgrade stash
  • sse.gleamupgrade_to_sse now stashes an upgrade thunk instead of calling Mist directly; the thunk accepts a list of headers and performs the upgrade with them
  • handler.gleam — after execute_route returns the middleware-modified response, checks for a stashed upgrade thunk; if found and status is 200, extracts headers from the response and calls the thunk

Safety 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):

  • CORS middleware headers appear on SSE response
  • Multiple stacked middleware headers all appear
  • Middleware rejection (401) prevents the SSE upgrade
  • CORS middleware does not interfere with event streaming

Docs: Added "Using Middleware with SSE" section to the SSE guide, updated changelog and release notes.

How

The fix uses a deferred upgrade pattern:

  1. Controller calls upgrade_to_sse → stashes a closure (the "upgrade thunk") in the process dictionary under upgrade_key, returns a dummy 200 response
  2. Middleware runs on the dummy response, adding CORS/security/other headers
  3. Handler detects the stashed thunk, extracts headers from the middleware-modified Dream response, and calls the thunk with those headers
  4. The thunk builds a Mist initial_response with all the headers and calls mist.server_sent_events

This is architecturally similar to the existing WebSocket stash-and-upgrade pattern, but uses a separate stash key because WebSocket's mist.websocket doesn't accept an initial_response parameter.

Test plan

  • 257 core unit tests pass
  • 8 SSE integration tests pass (3 existing + 4 new + 1 cucumber runner)
  • 9 WebSocket integration tests pass (no regression)
  • Pre-commit hooks pass (format, build all modules and examples)

## 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`.
@dcrockwell dcrockwell self-assigned this Mar 11, 2026
@dcrockwell dcrockwell added the bug Something isn't working label Mar 11, 2026
@dcrockwell dcrockwell merged commit b762bfc into develop Mar 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant