1
0
Files
2026-09-04 00:52:18 +05:30

97 lines
5.3 KiB
Markdown

# `sse-websocket` — a live dashboard over SSE and a chat over STOMP
Companion project for
[**Server-Sent Events and WebSocket on Spring Boot 4: SseEmitter, STOMP, and Which to Pick**](https://ankurm.com/spring-boot-4-sse-websocket-stomp/)
on ankurm.com.
Seventeen tests against a **real embedded Tomcat**, talking over real sockets. No Docker, no
broker to install, nothing but a JDK. [`./scripts/run-all.sh`](scripts/run-all.sh) regenerates
everything under [`docs/output/`](docs/output/).
## Versions
| | Version | Notes |
|---|---|---|
| JDK | 25 (Temurin 25.0.4.1+1) | current LTS |
| Spring Boot | 4.1.1 | latest GA of the 4.1 line |
| Spring Framework | 7.0.9 | Boot-managed |
| Tomcat | 11.0.24 | Boot-managed; its WebSocket buffer is the limit that matters |
| Jackson | 3.1.5 (`tools.jackson`) | why `JacksonJsonMessageConverter`, not `MappingJackson2MessageConverter` |
Read from `repo1.maven.org/.../maven-metadata.xml` and from Boot's own
`spring-boot-dependencies` POM, not from release announcements.
## Quickstart
```bash
./scripts/run-all.sh # every test, then regenerate docs/output/
mvn test # the same without the capture
mvn spring-boot:run # then: curl -N localhost:8080/sse/metrics
curl -s localhost:8080/diag # open streams, counters, converter order, container buffers
```
## Endpoints
| Path | What it is for |
|---|---|
| `GET /sse/metrics` | the dashboard stream; `SseEmitter(Long.MAX_VALUE)`, fed by a scheduled broadcast |
| `GET /sse/raw` | three events and a close, so the wire format can be captured verbatim |
| `GET /sse/payload` | the same record with and without a media type, plus the same for a `String` |
| `GET /sse/silent?timeoutMs=` | one event then silence, to observe an async timeout from the client side |
| `GET /sse/resume` | echoes `Last-Event-ID` and replays from it |
| `GET /diag` | emitter counters, `SimpUserRegistry` count, converter order, container buffer sizes |
| `GET /diag/async-timeout` | the effective async timeout, read off the live `AsyncContext` |
| `WS /ws` | the STOMP endpoint |
| `WS /ws-sockjs` | the SockJS fallback — a *separate* endpoint, not an option on the first |
STOMP destinations: `SEND /app/chat.send``/topic/room`; `SEND /app/chat.echo``/topic/chat.echo`
(no `@SendTo`, on purpose); `SEND /app/chat.whisper``/user/queue/whisper`.
## Profiles
| Profile | Effect |
|---|---|
| *(none)* | Tomcat's own WebSocket buffers — 8 192 bytes |
| `bigframes` | `ServletServerContainerFactoryBean` with 256 KB buffers; `-Dws.buffer-bytes=` to vary it |
## Documentation
1. [Two protocols, one question](docs/01-two-protocols.md)
2. [The SSE lifecycle, and the three ways a stream ends](docs/02-sse-lifecycle.md)
3. [The payload, and a media type that does less than you think](docs/03-the-payload.md)
4. [STOMP: four lines of configuration, three surprising defaults](docs/04-stomp.md)
5. [The limits: origin, size, and which one actually fires](docs/05-limits.md)
6. [Choosing, and when the answer is neither](docs/06-choosing.md)
## Captured output
| File | What it shows |
|---|---|
| [`sse-wire-format.txt`](docs/output/sse-wire-format.txt) | the raw bytes of an SSE response, headers included |
| [`sse-payload-conversion.txt`](docs/output/sse-payload-conversion.txt) | `data(x)` vs `data(x, APPLICATION_JSON)` for a record and a `String` |
| [`sse-lifecycle.txt`](docs/output/sse-lifecycle.txt) | an async timeout as the client sees it, and how many writes a dead client absorbs |
| [`async-timeout.txt`](docs/output/async-timeout.txt) | the container default and the property override, read off `AsyncContext` |
| [`sse-concurrency.txt`](docs/output/sse-concurrency.txt) | 40 open streams on a 10-thread connector |
| [`stomp-routing.txt`](docs/output/stomp-routing.txt) | fan-out, the default destination, the controller bypass, user destinations, presence |
| [`websocket-size-limits.txt`](docs/output/websocket-size-limits.txt) | the ceiling binary-searched at four container buffer sizes |
| [`handshake-origin.txt`](docs/output/handshake-origin.txt) | 101 / 101 / 403 for absent, same and foreign `Origin` |
| [`tests.txt`](docs/output/tests.txt) | 17 tests |
## Six things this module exists to prove
1. **SSE does not pin a thread per client.** Forty streams stay open on a connector pool capped
at ten, with zero active threads.
2. **An unset `spring.mvc.async.request-timeout` means 30 seconds on Tomcat 11.** Read off the
live `AsyncContext`, not quoted. That is why dashboards reconnect every 30 s with nothing in
the log.
3. **A dead SSE client is discovered by a failed write, and not by the first one.** One full
serialise-and-write succeeded into a socket whose client had already gone.
4. **The media type argument to `SseEmitter.data(..)` is a filter, not a preference.** For a
record it changes nothing; for a `String` it changes nothing either, because
`StringHttpMessageConverter` claims the write first.
5. **A STOMP client can publish straight to `/topic/**` and skip every controller.** The
timestamp the controller would have overwritten arrives untouched.
6. **Spring's 64 KB `messageSizeLimit` is never reached on a stock Tomcat.** The container's
8 192-byte buffer caps a STOMP frame at about 16 KB and closes the connection with status
1009 — no exception, no log line from Spring.