Add the sse-websocket module
This commit is contained in:
81
sse-websocket/docs/01-two-protocols.md
Normal file
81
sse-websocket/docs/01-two-protocols.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 1. Two protocols, one question
|
||||
|
||||
Next: [2. The SSE lifecycle](02-sse-lifecycle.md)
|
||||
|
||||
---
|
||||
|
||||
Server-Sent Events and WebSocket get compared as if they were rivals for the same job. They are
|
||||
not. The question that separates them is much smaller than "which is better", and it is this:
|
||||
|
||||
> **Does the client need to send anything after the first request?**
|
||||
|
||||
If the answer is no — a dashboard, a progress bar, a notification feed, a log tail, a
|
||||
"your export is ready" ping — SSE is the whole answer, and it is a return type rather than a
|
||||
subsystem. If the answer is yes, and the messages are frequent or need routing between clients,
|
||||
you want WebSocket, and almost certainly STOMP on top of it.
|
||||
|
||||
Everything else in the comparison follows from that one asymmetry.
|
||||
|
||||
## What each one costs you to set up
|
||||
|
||||
| | SSE | STOMP over WebSocket |
|
||||
|---|---|---|
|
||||
| Dependency | `spring-boot-starter-webmvc` | `spring-boot-starter-websocket` |
|
||||
| Auto-configuration involved | none | `spring-boot-websocket` |
|
||||
| Server-side surface | a handler method returning `SseEmitter` | `@EnableWebSocketMessageBroker`, a configurer, a broker, a converter stack |
|
||||
| Transport | one long-lived HTTP response | an HTTP/1.1 Upgrade to a framed, bidirectional connection |
|
||||
| Client | `new EventSource(url)`, built into every browser | a STOMP library |
|
||||
| Reconnect | automatic, with `Last-Event-ID` | yours to write |
|
||||
| Server knows who is connected | no | yes — `SimpUserRegistry`, connect/subscribe/disconnect events |
|
||||
| Payload | UTF-8 text only | text or binary |
|
||||
|
||||
The dependency line is the Boot 4 detail worth stating out loud. `spring-boot-starter-websocket`
|
||||
pulls in `spring-boot-websocket`, which is where the auto-configuration lives. Depending on
|
||||
`org.springframework:spring-websocket` and `spring-messaging` directly compiles and starts, and
|
||||
then nothing works — the same rule that catches people with `spring-kafka` in
|
||||
[`../kafka-basics`](../kafka-basics/README.md) and `spring-rabbit` in
|
||||
[`../rabbitmq`](../rabbitmq/README.md). In Boot 4, **depending on a library rather than on its
|
||||
Boot starter means you are missing its auto-configuration.**
|
||||
|
||||
## What SSE is not
|
||||
|
||||
Two things people assume, both wrong, both measured in this module:
|
||||
|
||||
**"It pins a thread per client."** It does not. `SseEmitter` switches the request to asynchronous
|
||||
mode; the request thread goes back to the pool immediately and the response stays open with
|
||||
nothing attached to it. [`SseConcurrencyTest`](../src/test/java/com/ankurm/ssews/SseConcurrencyTest.java)
|
||||
holds forty streams open against a connector whose pool is capped at ten:
|
||||
|
||||
```
|
||||
emitters open : 40
|
||||
connector pool size : 10 (max 10)
|
||||
connector active : 0
|
||||
```
|
||||
|
||||
**"You have to pass a media type or it calls `toString()`."** Also not true, and the opposite is
|
||||
closer to it. See [3. The payload](03-the-payload.md).
|
||||
|
||||
## What SSE genuinely cannot do
|
||||
|
||||
- **Take input.** `EventSource` issues one GET and never sends again. Client-to-server traffic has
|
||||
to go over ordinary HTTP requests, which is fine for low rates and awful for a chat.
|
||||
- **Send custom headers.** The browser's `EventSource` has no header API, so bearer-token
|
||||
authentication needs a cookie or a query parameter. `withCredentials` exists; `Authorization`
|
||||
does not.
|
||||
- **Carry binary.** The format is line-oriented UTF-8. Binary means base64, which costs a third.
|
||||
- **Tell you a client left.** There is no disconnect event, only a write that fails later —
|
||||
see [2. The SSE lifecycle](02-sse-lifecycle.md).
|
||||
- **Escape the browser's per-origin connection limit.** Over HTTP/1.1 a browser allows about six
|
||||
concurrent connections to one origin, and an open SSE stream is one of them, *per tab*. Four
|
||||
tabs of a dashboard is 4 streams; six is a hung site. HTTP/2 multiplexes and the limit
|
||||
effectively disappears, which makes "do we serve this over HTTP/2?" a real part of the decision.
|
||||
|
||||
## What STOMP costs
|
||||
|
||||
A broker, a message-converter stack, a session registry, an origin policy, and a set of routing
|
||||
rules whose defaults are not what you would guess. Chapters
|
||||
[4](04-stomp.md) and [5](05-limits.md) are that list.
|
||||
|
||||
---
|
||||
|
||||
Next: [2. The SSE lifecycle](02-sse-lifecycle.md)
|
||||
132
sse-websocket/docs/02-sse-lifecycle.md
Normal file
132
sse-websocket/docs/02-sse-lifecycle.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# 2. The SSE lifecycle, and the three ways a stream ends
|
||||
|
||||
Previous: [1. Two protocols](01-two-protocols.md) · Next: [3. The payload](03-the-payload.md)
|
||||
|
||||
---
|
||||
|
||||
An `SseEmitter` has exactly one interesting property: **nothing owns it.** Spring does not keep a
|
||||
list, does not notify you when one dies, and does not clean up after you. Every SSE bug in
|
||||
production is a variation on that sentence.
|
||||
|
||||
## The wire format
|
||||
|
||||
[`docs/output/sse-wire-format.txt`](output/sse-wire-format.txt), captured from a raw socket by
|
||||
[`SseWireFormatTest`](../src/test/java/com/ankurm/ssews/SseWireFormatTest.java):
|
||||
|
||||
```
|
||||
HTTP/1.1 200
|
||||
Content-Type: text/event-stream
|
||||
Transfer-Encoding: chunked
|
||||
|
||||
:stream open
|
||||
|
||||
id:1
|
||||
event:metric
|
||||
retry:3000
|
||||
data:{"seq":1,"host":"node-a","cpu":0.42,"heapMb":512,"at":"2026-09-03T10:00:00Z"}
|
||||
|
||||
id:2
|
||||
event:note
|
||||
data:line one
|
||||
data:line two
|
||||
```
|
||||
|
||||
Points that matter:
|
||||
|
||||
- **No `Content-Length`**, so the response is chunked. Any intermediary that buffers a chunked
|
||||
response defeats SSE entirely; that is why `X-Accel-Buffering: no` exists for nginx.
|
||||
- **A blank line terminates an event.** Two `data:` lines in one event are rejoined by the client
|
||||
with `\n` — a multi-line string does not become two events.
|
||||
- **A line starting with `:` is a comment** and is discarded. Sending one periodically is the
|
||||
standard trick for keeping an idle stream alive through a proxy that reaps quiet connections.
|
||||
- **`retry:` sets the client's reconnect delay** in milliseconds, and it is sticky.
|
||||
- **`id:` is what comes back as `Last-Event-ID`** on the browser's automatic reconnect. See
|
||||
`/sse/resume` for the replay shape; almost no server implements it, which is why "SSE
|
||||
guarantees delivery" is false in practice.
|
||||
|
||||
## Ending 1: the client goes away
|
||||
|
||||
The server does not find out until it writes. `SseDisconnectTest` closes a socket, then
|
||||
broadcasts, and counts:
|
||||
|
||||
```
|
||||
broadcasts before the write failed : 2
|
||||
registry.sendsOk : 1
|
||||
registry.sendsFailed : 0 -> 1
|
||||
```
|
||||
|
||||
The **first** write after the client vanished succeeded. TCP accepted the bytes into the socket
|
||||
buffer and only surfaced the reset on the write after that. So on any dashboard, at least one full
|
||||
serialise-and-write happens into a dead connection, and on a slow-to-reset path it can be several.
|
||||
|
||||
The consequence for your code: **the broadcast loop must catch and drop**, and it must catch two
|
||||
things. `IOException` is the socket. `IllegalStateException` is the emitter having already been
|
||||
completed or timed out on a different thread — and if that escapes onto a `@Scheduled`
|
||||
method, the schedule stops and every client's dashboard freezes at once.
|
||||
|
||||
## Ending 2: the async request times out
|
||||
|
||||
`SseTimeoutTest` opens a stream with a 1.2-second emitter timeout, sends one event, then waits.
|
||||
[`docs/output/sse-lifecycle.txt`](output/sse-lifecycle.txt):
|
||||
|
||||
```
|
||||
HTTP/1.1 200
|
||||
Content-Type: text/event-stream
|
||||
Transfer-Encoding: chunked
|
||||
|
||||
event:hello
|
||||
data:then silence
|
||||
|
||||
0
|
||||
=== stream ended after ~2165 ms ===
|
||||
```
|
||||
|
||||
**Nothing marks the timeout.** No 503, no error event, no trailer. The response was committed with
|
||||
200 when the first byte went out, so closing the socket is the only thing left to do, and the
|
||||
browser's `EventSource` treats that as an ordinary disconnect and reconnects. Server-side it is
|
||||
visible: `onTimeout` ran and the registry's counter moved.
|
||||
|
||||
Note the ~2.1 s for a 1.2 s timeout. Tomcat checks async timeouts from its background
|
||||
processor, so expiry is granular to about a second rather than exact.
|
||||
|
||||
### The number that decides how long a stream lives
|
||||
|
||||
Three levels, checked in order:
|
||||
|
||||
1. `new SseEmitter(millis)` — per handler
|
||||
2. `spring.mvc.async.request-timeout` — per application
|
||||
3. whatever the servlet container defaults to — invisible
|
||||
|
||||
`AsyncTimeoutUnsetTest` reads the third off the live `AsyncContext` rather than quoting it
|
||||
([`async-timeout.txt`](output/async-timeout.txt)):
|
||||
|
||||
```
|
||||
{"spring.mvc.async.request-timeout":"<unset>","servletContainer":"Apache Tomcat/11.0.24","effectiveAsyncTimeoutMs":30000}
|
||||
{"spring.mvc.async.request-timeout":"5s","servletContainer":"Apache Tomcat/11.0.24","effectiveAsyncTimeoutMs":5000}
|
||||
```
|
||||
|
||||
**Thirty seconds.** A dashboard that silently reconnects every thirty seconds, with nothing in the
|
||||
log, is this and nothing else. `new SseEmitter(Long.MAX_VALUE)` is the usual fix, and it is also
|
||||
what turns a leaked emitter into a permanent one.
|
||||
|
||||
Reading it took some care: inside the handler method `request.isAsyncStarted()` is still `false`,
|
||||
because Spring starts async processing *after* the method returns and it has seen the return type.
|
||||
The first place the real `AsyncContext` exists is
|
||||
`AsyncHandlerInterceptor.afterConcurrentHandlingStarted`.
|
||||
|
||||
## Ending 3: you complete it
|
||||
|
||||
`emitter.complete()`, or `completeWithError(ex)`. The second writes nothing useful to a stream
|
||||
that is already committed — if you want the client to know something went wrong, send an
|
||||
`event:error` of your own *first*, then complete.
|
||||
|
||||
## Therefore: a registry
|
||||
|
||||
[`EmitterRegistry`](../src/main/java/com/ankurm/ssews/EmitterRegistry.java) exists because of the
|
||||
three endings above. It removes on **all three** callbacks, not just `onCompletion`. Registering
|
||||
only `onCompletion` is the common version of this bug: a timed-out emitter is never completed by
|
||||
the client, so it stays in the map for ever and every subsequent broadcast pays for it.
|
||||
|
||||
---
|
||||
|
||||
Previous: [1. Two protocols](01-two-protocols.md) · Next: [3. The payload](03-the-payload.md)
|
||||
83
sse-websocket/docs/03-the-payload.md
Normal file
83
sse-websocket/docs/03-the-payload.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 3. The payload, and a media type that does less than you think
|
||||
|
||||
Previous: [2. The SSE lifecycle](02-sse-lifecycle.md) · Next: [4. STOMP](04-stomp.md)
|
||||
|
||||
---
|
||||
|
||||
The advice you will find is: always pass a media type to `data(..)`, or your object is written
|
||||
with `toString()`. Both halves of that turn out to be wrong on Boot 4.1, and the mechanism
|
||||
underneath explains a real trap that the advice misses.
|
||||
|
||||
## What actually happens
|
||||
|
||||
`SseEmitter.send(Object data, MediaType mediaType)` walks the application's configured
|
||||
`HttpMessageConverter` list and uses **the first converter whose `canWrite(type, mediaType)`
|
||||
returns true**. The media type argument is a *filter*, not a preference. A `null` media type
|
||||
filters nothing, so the first converter that can write the type at all wins.
|
||||
|
||||
`/diag` prints the list in order:
|
||||
|
||||
```
|
||||
ByteArrayHttpMessageConverter
|
||||
StringHttpMessageConverter
|
||||
ResourceHttpMessageConverter
|
||||
ResourceRegionHttpMessageConverter
|
||||
AllEncompassingFormHttpMessageConverter
|
||||
JacksonJsonHttpMessageConverter
|
||||
Jaxb2RootElementHttpMessageConverter
|
||||
```
|
||||
|
||||
Two consequences, both measured by
|
||||
[`SseDataConversionTest`](../src/test/java/com/ankurm/ssews/SseDataConversionTest.java)
|
||||
([`sse-payload-conversion.txt`](output/sse-payload-conversion.txt)):
|
||||
|
||||
**For a record or POJO, the media type changes nothing.** No converter ahead of Jackson claims it,
|
||||
so Jackson writes it either way:
|
||||
|
||||
```
|
||||
=== SseEmitter.event().data(record, APPLICATION_JSON) ===
|
||||
{"seq":7,"host":"node-a","cpu":0.42,"heapMb":512,"at":"2026-09-03T10:00:00Z"}
|
||||
=== SseEmitter.event().data(record) [no media type] ===
|
||||
{"seq":7,"host":"node-a","cpu":0.42,"heapMb":512,"at":"2026-09-03T10:00:00Z"}
|
||||
=== POJO: identical? true ===
|
||||
```
|
||||
|
||||
**For a `String`, the media type *still* changes nothing** — and this is the part that
|
||||
surprised me enough to rewrite the test. `StringHttpMessageConverter` supports `MediaType.ALL` and
|
||||
sits ahead of Jackson, so it claims the write even when you explicitly ask for
|
||||
`application/json`:
|
||||
|
||||
```
|
||||
=== String payload, no media type -> {"already":"json"}
|
||||
=== String payload, APPLICATION_JSON -> {"already":"json"}
|
||||
```
|
||||
|
||||
There is no way to make `send()` JSON-quote a `String`. Good news if you are streaming
|
||||
pre-rendered JSON — it goes out verbatim, not double-encoded. A trap if you assumed a
|
||||
`String` field would be escaped for you: it will not be, and a payload containing a newline
|
||||
becomes two `data:` lines rather than one.
|
||||
|
||||
## Jackson 3, and the `Instant` that proves it
|
||||
|
||||
`"at":"2026-09-03T10:00:00Z"` — an ISO-8601 string, not an epoch number. Boot 4.1.1 manages
|
||||
Jackson **3.1.5** under `tools.jackson`, and `JacksonJsonHttpMessageConverter` is the Jackson 3
|
||||
converter. The Jackson 2 sibling (`MappingJackson2HttpMessageConverter`) is still on the
|
||||
classpath in the messaging stack.
|
||||
|
||||
**Rule for Boot 4: a `2` in a Spring class name means the *previous* Jackson.** The unnumbered
|
||||
name is current, which is the opposite of the convention you would guess, and it is the same fork
|
||||
that catches people in [`../kafka-basics`](../kafka-basics/README.md) and
|
||||
[`../rabbitmq`](../rabbitmq/README.md). For STOMP it is
|
||||
`JacksonJsonMessageConverter` you want, not `MappingJackson2MessageConverter`.
|
||||
|
||||
## Practical guidance
|
||||
|
||||
- Pass `MediaType.APPLICATION_JSON` anyway. It costs nothing, it documents intent, and it makes
|
||||
the code correct against a converter list you did not configure yourself.
|
||||
- Do not stream a `String` and assume it is escaped. Send the object.
|
||||
- Keep events small. Every subscriber pays for one serialisation per event; see
|
||||
[6. Choosing](06-choosing.md).
|
||||
|
||||
---
|
||||
|
||||
Previous: [2. The SSE lifecycle](02-sse-lifecycle.md) · Next: [4. STOMP](04-stomp.md)
|
||||
129
sse-websocket/docs/04-stomp.md
Normal file
129
sse-websocket/docs/04-stomp.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 4. STOMP: four lines of configuration, three surprising defaults
|
||||
|
||||
Previous: [3. The payload](03-the-payload.md) · Next: [5. The limits](05-limits.md)
|
||||
|
||||
---
|
||||
|
||||
[`WebSocketConfig`](../src/main/java/com/ankurm/ssews/WebSocketConfig.java) is short. Each line
|
||||
decides more than it looks like it does.
|
||||
|
||||
```java
|
||||
registry.enableSimpleBroker("/topic", "/queue");
|
||||
registry.setApplicationDestinationPrefixes("/app");
|
||||
registry.setUserDestinationPrefix("/user");
|
||||
```
|
||||
|
||||
## Surprise 1: a client can publish straight to a topic
|
||||
|
||||
`setApplicationDestinationPrefixes("/app")` says which destinations reach a `@MessageMapping`.
|
||||
It does **not** say which destinations a client may send to. A `SEND` to `/topic/room` goes
|
||||
directly to the broker, and every subscriber receives it.
|
||||
[`StompRoutingTest`](../src/test/java/com/ankurm/ssews/StompRoutingTest.java) sends a message with
|
||||
a timestamp the controller would have overwritten, and it arrives untouched:
|
||||
|
||||
```
|
||||
=== SEND straight to /topic/room ===
|
||||
received: ChatMessage[from=not-checked-by-anyone, text=unvalidated, at=2000-01-01T00:00:00Z]
|
||||
=== the controller's Instant.now() rewrite did NOT happen ===
|
||||
```
|
||||
|
||||
No handler ran. No validation ran. Nothing logged that anything was skipped. If your controller is
|
||||
where authorisation, sanitisation or rate limiting happens, a client that knows the destination
|
||||
name walks past all of it.
|
||||
|
||||
The fix is not a routing setting — it is Spring Security's message-level authorisation
|
||||
(`simpDestMatchers("/topic/**").denyAll()` for client `SEND` frames), or a broker relay whose
|
||||
own ACLs forbid publishing. Treat a bare simple broker as an open pipe.
|
||||
|
||||
## Surprise 2: a handler with no `@SendTo` is not private
|
||||
|
||||
The default destination for a `@MessageMapping` return value is the broker prefix plus the
|
||||
mapping. `@MessageMapping("/chat.echo")` with no annotation publishes to **`/topic/chat.echo`**:
|
||||
|
||||
```
|
||||
=== @MessageMapping("/chat.echo") with NO @SendTo ===
|
||||
subscribed to /topic/chat.echo, received: ChatMessage[from=echo, text=who can see this?, ...]
|
||||
```
|
||||
|
||||
Anyone who can guess the mapping can subscribe to the replies. If a handler's answer is for the
|
||||
caller only, it needs `@SendToUser`, not silence.
|
||||
|
||||
## Surprise 3: `convertAndSendToUser` needs more than a name
|
||||
|
||||
`/user/**` is not a real destination. On `SUBSCRIBE`, `DefaultUserDestinationResolver` rewrites
|
||||
`/user/queue/whisper` into a session-scoped destination, which is why two clients subscribed to
|
||||
the same string get different messages:
|
||||
|
||||
```
|
||||
Translated /user/queue/whisper -> [/queue/whisper-userea271ead-...]
|
||||
Translated /user/queue/whisper -> [/queue/whisper-user84865e50-...]
|
||||
```
|
||||
|
||||
With no authenticated `Principal`, the "user" is the STOMP session id — and the three-argument
|
||||
`convertAndSendToUser(sessionId, dest, payload)` **silently delivers nothing**, because the
|
||||
resolver looks that name up in a user registry that has never heard of it. No exception, no log
|
||||
line. The message has to carry the session id too:
|
||||
|
||||
```java
|
||||
SimpMessageHeaderAccessor out = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
|
||||
out.setSessionId(targetSessionId);
|
||||
out.setLeaveMutable(true); // without this the accessor is frozen before the resolver looks
|
||||
template.convertAndSendToUser(targetSessionId, "/queue/whisper", payload, out.getMessageHeaders());
|
||||
```
|
||||
|
||||
And there is a second trap stacked on the first, which cost a debugging round here:
|
||||
|
||||
> **`StompSession.getSessionId()` on the client is not the server's session id.**
|
||||
> ```
|
||||
> alice.getSessionId() [client side] : 08b58570-0fcb-ffaf-d82c-119232e66561
|
||||
> server-side session id : f882cf6a-f0d9-48e3-baf3-c573045a47d1
|
||||
> ```
|
||||
> Whispering to the client-side value delivers nothing, silently, exactly like the missing header
|
||||
> does. The server-side id is what appears in `SimpMessageHeaderAccessor.getSessionId()`, and it
|
||||
> has to come from the server.
|
||||
|
||||
With Spring Security in the picture the `Principal` is a real user name and none of this applies
|
||||
— which is a good argument for putting security in front of a STOMP endpoint even when you
|
||||
do not think you need authentication yet.
|
||||
|
||||
## What STOMP gives you that SSE cannot
|
||||
|
||||
Presence. [`PresenceListener`](../src/main/java/com/ankurm/ssews/PresenceListener.java) is fifteen
|
||||
lines and produces:
|
||||
|
||||
```
|
||||
| CONNECTED d775c936-8492-4b32-871e-c659346a2fb9
|
||||
| SUBSCRIBE d775c936-8492-4b32-871e-c659346a2fb9 -> /topic/room
|
||||
| DISCONNECT def64a1f-dc10-45bc-92d3-aa2e842ae02f status=CloseStatus[code=1000, reason=null]
|
||||
```
|
||||
|
||||
The server knows who is connected, what they subscribed to, and when they left, with a close
|
||||
status attached. SSE has no equivalent of any of those three.
|
||||
|
||||
## The simple broker is not a broker
|
||||
|
||||
`enableSimpleBroker` is in-memory, single JVM, no persistence, no acknowledgement, and it drops
|
||||
everything on restart. It is also fine for a great many applications. When it is not,
|
||||
`enableStompBrokerRelay("/topic", "/queue")` swaps in RabbitMQ or ActiveMQ **without touching a
|
||||
single controller** — the annotations and destinations stay exactly as they are. The broker
|
||||
side of that is [`../rabbitmq`](../rabbitmq/README.md).
|
||||
|
||||
Note the asymmetry once you relay: `/topic` and `/queue` become real broker destinations with
|
||||
real semantics, and the "client can publish straight to a topic" problem becomes the broker's ACL
|
||||
problem rather than yours.
|
||||
|
||||
## SockJS is a second endpoint, not an option on the first
|
||||
|
||||
```java
|
||||
registry.addEndpoint("/ws");
|
||||
registry.addEndpoint("/ws-sockjs").withSockJS();
|
||||
```
|
||||
|
||||
`.withSockJS()` on the same path does not give you both. SockJS rewrites the URL into
|
||||
`/{server}/{session}/{transport}`, and a plain WebSocket client pointed at that path fails the
|
||||
handshake. In 2026, SockJS is mostly legacy — WebSocket support is universal — but it
|
||||
is still the answer for a corporate proxy that strips `Upgrade`.
|
||||
|
||||
---
|
||||
|
||||
Previous: [3. The payload](03-the-payload.md) · Next: [5. The limits](05-limits.md)
|
||||
119
sse-websocket/docs/05-limits.md
Normal file
119
sse-websocket/docs/05-limits.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 5. The limits: origin, size, and which one actually fires
|
||||
|
||||
Previous: [4. STOMP](04-stomp.md) · Next: [6. Choosing](06-choosing.md)
|
||||
|
||||
---
|
||||
|
||||
## Origin: checked by default, and the check is real
|
||||
|
||||
The WebSocket handshake is an ordinary HTTP GET, and the browser's same-origin policy **does not
|
||||
apply to it**. Without a server-side check, a page on any site could open a WebSocket to your
|
||||
server with the user's cookies attached. Spring checks `Origin` by default.
|
||||
[`WebSocketHandshakeOriginTest`](../src/test/java/com/ankurm/ssews/WebSocketHandshakeOriginTest.java)
|
||||
varies only that header ([`handshake-origin.txt`](output/handshake-origin.txt)):
|
||||
|
||||
```
|
||||
no Origin header -> HTTP/1.1 101
|
||||
Origin: http://localhost -> HTTP/1.1 101
|
||||
Origin: https://evil... -> HTTP/1.1 403
|
||||
```
|
||||
|
||||
The middle row is the policy working. The **first** row is the one to understand: a client that
|
||||
sends no `Origin` at all is allowed through, which is why every Java test in this module connects
|
||||
without ceremony — and why `Origin` is a browser control, not an authentication mechanism.
|
||||
For a real cross-origin front end use `setAllowedOriginPatterns("https://app.example.com")`;
|
||||
`setAllowedOrigins("*")` is rejected outright when credentials are allowed.
|
||||
|
||||
## Size: two limits, and the documented one is not the one that fires
|
||||
|
||||
`WebSocketTransportRegistration.setMessageSizeLimit` defaults to 64 KB and is what every
|
||||
guide tells you to raise. Underneath it sits the servlet container's own WebSocket buffer, which
|
||||
`/diag` reports as:
|
||||
|
||||
```
|
||||
"ws.container.defaultMaxTextMessageBufferSize":8192
|
||||
```
|
||||
|
||||
Spring's `SubProtocolWebSocketHandler` does not support partial messages, so the container has to
|
||||
assemble each text message whole before Spring sees it. Cross the container's ceiling and the
|
||||
connection is **closed** — WebSocket status 1009, `The decoded text message was too big for
|
||||
the output buffer and the endpoint does not support partial messages` — before Spring's
|
||||
limit is ever consulted.
|
||||
|
||||
[`StompSizeLimitDefaultBufferTest`](../src/test/java/com/ankurm/ssews/StompSizeLimitDefaultBufferTest.java)
|
||||
binary-searches for the exact ceiling
|
||||
([`websocket-size-limits.txt`](output/websocket-size-limits.txt)):
|
||||
|
||||
| container buffer | largest body delivered | smallest rejected |
|
||||
|---|---|---|
|
||||
| 8 192 (Tomcat default) | 16 459 | 16 522 |
|
||||
| 16 384 | 16 446 | 16 496 |
|
||||
| 32 768 | 32 615 | 32 665 |
|
||||
| 65 536 | 65 350 | 65 400 |
|
||||
|
||||
Body length is the JSON string field; the STOMP frame adds about 90 bytes of headers on top, so
|
||||
each row is a frame just under 16 KB, 16 KB, 32 KB and 64 KB respectively.
|
||||
|
||||
Read that table honestly: **the ceiling behaves like `max(16 KB, buffer)`**. Setting the buffer to
|
||||
16 KB changes nothing from the default; above 16 KB the ceiling follows it. I have not
|
||||
reduced the 16 KB floor to a line in Tomcat's source and will not claim a mechanism I have
|
||||
not read. What is certain, and is the point:
|
||||
|
||||
> **Spring's 64 KB `messageSizeLimit` is not reached on a stock Tomcat.** The real ceiling is
|
||||
> about 16 KB, it is enforced by the container, and raising Spring's limit alone does
|
||||
> nothing whatsoever.
|
||||
|
||||
The fix is a `ServletServerContainerFactoryBean`
|
||||
([`WebSocketBufferConfig`](../src/main/java/com/ankurm/ssews/WebSocketBufferConfig.java), active
|
||||
under the `bigframes` profile):
|
||||
|
||||
```java
|
||||
@Bean
|
||||
public ServletServerContainerFactoryBean createWebSocketContainer() {
|
||||
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
|
||||
container.setMaxTextMessageBufferSize(256 * 1024);
|
||||
container.setMaxBinaryMessageBufferSize(256 * 1024);
|
||||
return container;
|
||||
}
|
||||
```
|
||||
|
||||
### The two failures look different, and only one is diagnosable
|
||||
|
||||
With the buffer at 256 KB, Spring's limit finally decides — and it fails *better*:
|
||||
|
||||
```
|
||||
60 KB body -> delivered, body length 61440
|
||||
80 KB body -> NOT delivered, connection dropped
|
||||
org.springframework.messaging.simp.stomp.StompConversionException: The configured STOMP buffer size limit of 65536 bytes has been exceeded
|
||||
```
|
||||
|
||||
Spring logs the exception, names the limit, and sends a STOMP `ERROR` frame the client can see.
|
||||
The container's refusal does none of that. So the practical advice is not just "raise the buffer"
|
||||
— it is **raise the container buffer above Spring's limit deliberately, so that the limit
|
||||
that fires is the one that tells you it fired.**
|
||||
|
||||
### The client has the same limit
|
||||
|
||||
A `WebSocketStompClient` built on `StandardWebSocketClient` inherits the same 8 KB default,
|
||||
and hits it on its *own* session first. That produced a false positive while writing these tests:
|
||||
a message that looked rejected by the server was actually refused by the client.
|
||||
[`SizeProbe`](../src/test/java/com/ankurm/ssews/SizeProbe.java) raises the client side to 1 MB
|
||||
before measuring anything.
|
||||
|
||||
## Send buffer and send time
|
||||
|
||||
Two limits that bite in production rather than in tests, both set in `WebSocketConfig`:
|
||||
|
||||
- `setSendBufferSizeLimit` (512 KB here) — how much undelivered output Spring will hold
|
||||
for one slow client before closing its session. Without it, one client on hotel wifi can grow
|
||||
the heap until the process dies.
|
||||
- `setSendTimeLimit` (20 s here) — how long a single send may take before the session is
|
||||
closed.
|
||||
|
||||
Both exist because a broadcast is only as fast as its slowest subscriber, and a WebSocket session
|
||||
has no equivalent of TCP's back-pressure reaching your application code. This is precisely the gap
|
||||
RSocket closes, and the reason the [next article](06-choosing.md) exists.
|
||||
|
||||
---
|
||||
|
||||
Previous: [4. STOMP](04-stomp.md) · Next: [6. Choosing](06-choosing.md)
|
||||
80
sse-websocket/docs/06-choosing.md
Normal file
80
sse-websocket/docs/06-choosing.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# 6. Choosing, and when the answer is neither
|
||||
|
||||
Previous: [5. The limits](05-limits.md)
|
||||
|
||||
---
|
||||
|
||||
## The decision, in one table
|
||||
|
||||
| If | Use | Because |
|
||||
|---|---|---|
|
||||
| Server pushes, client never speaks | **SSE** | A return type. No broker, no converter stack, no origin policy. Reconnect is free. |
|
||||
| Clients talk to each other, or to the server, often | **STOMP over WebSocket** | Routing, presence, per-user destinations, and a path to a real broker without touching controllers. |
|
||||
| One-off progress on an operation the client already started | **SSE** | The stream is scoped to the request. Nothing to clean up. |
|
||||
| Payloads are binary, or large | **WebSocket** | SSE is line-oriented UTF-8; binary costs a third in base64. |
|
||||
| The consumer is another service, not a browser | **neither** — see below | Browsers are the only reason to accept SSE's or STOMP's constraints. |
|
||||
| You need the consumer to say "slow down" | **neither** | Covered in the next article. |
|
||||
|
||||
## The costs nobody puts in the table
|
||||
|
||||
**SSE costs a connection per stream, per tab.** Over HTTP/1.1 a browser allows about six
|
||||
concurrent connections per origin, and an open `EventSource` is one of them. Three tabs of your
|
||||
dashboard leaves three for everything else on the page. Over HTTP/2 they multiplex over one
|
||||
connection and the limit effectively disappears — so "are we behind HTTP/2 end to end,
|
||||
including the proxy?" is a real input to this decision, not a detail.
|
||||
|
||||
**SSE costs one serialisation per subscriber per event.** The fan-out in
|
||||
[`EmitterRegistry.broadcast`](../src/main/java/com/ankurm/ssews/EmitterRegistry.java) is a loop:
|
||||
a thousand open dashboards means a thousand `send()` calls on one scheduler thread, every tick.
|
||||
The obvious fix — serialise once and write the bytes N times — is not something
|
||||
`SseEmitter` offers.
|
||||
|
||||
**SSE does not pin threads, though.** That objection is wrong, and
|
||||
[`sse-concurrency.txt`](output/sse-concurrency.txt) is the evidence:
|
||||
|
||||
```
|
||||
=== server.tomcat.threads.max=10, clients=40 ===
|
||||
emitters open : 40
|
||||
connector pool size : 10 (max 10)
|
||||
connector active : 0
|
||||
one broadcast reached: 40 streams
|
||||
clients that read it : 40
|
||||
```
|
||||
|
||||
**STOMP costs a stateful server.** Sessions live in one JVM's memory. Two instances behind a load
|
||||
balancer do not share subscriptions, so a message published on instance A never reaches a
|
||||
subscriber on instance B — which is the real reason to move to a broker relay, well before
|
||||
you need persistence.
|
||||
|
||||
## Should you build this at all?
|
||||
|
||||
Three cases where the honest answer is no:
|
||||
|
||||
- **Updates arrive every few minutes.** Polling every thirty seconds is one endpoint, no
|
||||
lifecycle, no proxy configuration, no reconnect logic and no leaked emitters. The right answer
|
||||
more often than it is chosen.
|
||||
- **You want a chat and you have one server and no plan for a second.** Everything in
|
||||
[chapter 4](04-stomp.md) is real work, and the first time you scale out you will be doing it
|
||||
again with a broker relay. Decide about the broker first.
|
||||
- **The client is another service.** SSE's advantage is that browsers implement it. STOMP's
|
||||
advantage is that browsers can speak it. Neither advantage applies service-to-service, and
|
||||
neither protocol gives you flow control, deadlines or a schema — which is what the
|
||||
companion article on RSocket, gRPC and raw WebSocket is about.
|
||||
|
||||
## Everything else, one line each
|
||||
|
||||
- **Behind nginx**, a chunked SSE response is buffered by default and the client sees nothing
|
||||
until the buffer fills. `proxy_buffering off` or the `X-Accel-Buffering: no` response header.
|
||||
- **`EventSource` cannot send an `Authorization` header.** Cookie, or a query parameter you then
|
||||
have to keep out of access logs.
|
||||
- **Heartbeats.** SSE: send a `:` comment every N seconds. STOMP: `setHeartbeatValue` plus a
|
||||
`TaskScheduler`, and both sides must agree.
|
||||
- **Compression.** `text/event-stream` compresses extremely well and Boot's HTTP compression is
|
||||
off by default; `server.compression.mime-types` must list it explicitly.
|
||||
- **Surefire does not discover static nested test classes.** Seven of this module's seventeen
|
||||
tests silently did not run until they were split into top-level classes. Check the count, not
|
||||
the colour.
|
||||
|
||||
---
|
||||
|
||||
Previous: [5. The limits](05-limits.md)
|
||||
4
sse-websocket/docs/output/async-timeout.txt
Normal file
4
sse-websocket/docs/output/async-timeout.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
=== spring.mvc.async.request-timeout ABSENT ===
|
||||
{"spring.mvc.async.request-timeout":"<unset>","servletContainer":"Apache Tomcat/11.0.24","effectiveAsyncTimeoutMs":30000}
|
||||
=== spring.mvc.async.request-timeout=5s ===
|
||||
{"spring.mvc.async.request-timeout":"5s","servletContainer":"Apache Tomcat/11.0.24","effectiveAsyncTimeoutMs":5000}
|
||||
4
sse-websocket/docs/output/handshake-origin.txt
Normal file
4
sse-websocket/docs/output/handshake-origin.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
=== GET /ws handshake, varying only the Origin header ===
|
||||
no Origin header -> HTTP/1.1 101
|
||||
Origin: http://localhost -> HTTP/1.1 101
|
||||
Origin: https://evil... -> HTTP/1.1 403
|
||||
6
sse-websocket/docs/output/sse-concurrency.txt
Normal file
6
sse-websocket/docs/output/sse-concurrency.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
=== server.tomcat.threads.max=10, clients=40 ===
|
||||
emitters open : 40
|
||||
connector pool size : 10 (max 10)
|
||||
connector active : 0
|
||||
one broadcast reached: 40 streams
|
||||
clients that read it : 40
|
||||
23
sse-websocket/docs/output/sse-lifecycle.txt
Normal file
23
sse-websocket/docs/output/sse-lifecycle.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
=== GET /sse/silent?timeoutMs=1200 : whole response ===
|
||||
| HTTP/1.1 200
|
||||
| Content-Type: text/event-stream
|
||||
| Transfer-Encoding: chunked
|
||||
| Date: Thu, 03 Sep 2026 18:59:34 GMT
|
||||
| Connection: close
|
||||
|
|
||||
| 1f
|
||||
| event:hello
|
||||
| data:then silence
|
||||
|
|
||||
|
|
||||
| 0
|
||||
|
|
||||
=== stream ended after ~1697 ms ===
|
||||
=== registry.timedOut(): 0 -> 1 ===
|
||||
|
||||
=== client closed the socket, then: ===
|
||||
broadcasts before the write failed : 2
|
||||
registry.sendsOk : 1
|
||||
registry.sendsFailed : 0 -> 1
|
||||
registry.open : 0
|
||||
=== writes that appeared to succeed after the client was gone: 1 ===
|
||||
8
sse-websocket/docs/output/sse-payload-conversion.txt
Normal file
8
sse-websocket/docs/output/sse-payload-conversion.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
=== SseEmitter.event().data(record, APPLICATION_JSON) ===
|
||||
{"seq":7,"host":"node-a","cpu":0.42,"heapMb":512,"at":"2026-09-03T10:00:00Z"}
|
||||
=== SseEmitter.event().data(record) [no media type] ===
|
||||
{"seq":7,"host":"node-a","cpu":0.42,"heapMb":512,"at":"2026-09-03T10:00:00Z"}
|
||||
=== POJO: identical? true ===
|
||||
=== String payload, no media type -> {"already":"json"}
|
||||
=== String payload, APPLICATION_JSON -> {"already":"json"}
|
||||
=== String payload, APPLICATION_JSON -> {"already":"json"}
|
||||
22
sse-websocket/docs/output/sse-wire-format.txt
Normal file
22
sse-websocket/docs/output/sse-wire-format.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
=== GET /sse/raw : raw bytes, one line per row ===
|
||||
| HTTP/1.1 200
|
||||
| Content-Type: text/event-stream
|
||||
| Transfer-Encoding: chunked
|
||||
| Date: Thu, 03 Sep 2026 18:59:33 GMT
|
||||
| Connection: close
|
||||
|
|
||||
| ac
|
||||
| :stream open
|
||||
|
|
||||
| id:1
|
||||
| event:metric
|
||||
| retry:3000
|
||||
| data:{"seq":1,"host":"node-a","cpu":0.42,"heapMb":512,"at":"2026-09-03T10:00:00Z"}
|
||||
|
|
||||
| id:2
|
||||
| event:note
|
||||
| data:line one
|
||||
| data:line two
|
||||
|
|
||||
|
|
||||
| 0
|
||||
23
sse-websocket/docs/output/stomp-routing.txt
Normal file
23
sse-websocket/docs/output/stomp-routing.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
=== SEND /app/chat.send -> @SendTo("/topic/room") ===
|
||||
alice received : ChatMessage[from=alice, text=is this thing on?, at=2026-09-03T18:59:36.632180396Z]
|
||||
bob received : ChatMessage[from=alice, text=is this thing on?, at=2026-09-03T18:59:36.632180396Z]
|
||||
=== presence events the server saw ===
|
||||
| CONNECTED 122616fa-6e84-4c09-a317-6dee68f17794
|
||||
| CONNECTED 22633c5f-f886-4ff2-94d0-156e43d92870
|
||||
| SUBSCRIBE 122616fa-6e84-4c09-a317-6dee68f17794 -> /topic/room
|
||||
| SUBSCRIBE 22633c5f-f886-4ff2-94d0-156e43d92870 -> /topic/room
|
||||
| DISCONNECT 122616fa-6e84-4c09-a317-6dee68f17794 status=CloseStatus[code=1002, reason=null]
|
||||
|
||||
=== @MessageMapping("/chat.echo") with NO @SendTo ===
|
||||
subscribed to /topic/chat.echo, received: ChatMessage[from=echo, text=who can see this?, at=2026-09-03T18:59:38.089388303Z]
|
||||
|
||||
=== SEND straight to /topic/room ===
|
||||
received: ChatMessage[from=not-checked-by-anyone, text=unvalidated, at=2000-01-01T00:00:00Z]
|
||||
=== the controller's Instant.now() rewrite did NOT happen ===
|
||||
|
||||
alice.getSessionId() [client side] : 97d9d368-53ab-0f29-0dd4-2c0515124971
|
||||
server-side session id : c6ddbdda-36a6-4a9e-8a15-6714379ecdb9
|
||||
=== convertAndSendToUser(aliceSession, "/queue/whisper", ..) ===
|
||||
both subscribed to the SAME string : /user/queue/whisper
|
||||
alice received : ChatMessage[from=42eda7c9-16b6-4ffc-bf68-60e8edfe4239, text=just for you, at=2026-09-03T19:00:15.135586620Z]
|
||||
bob received : null
|
||||
16
sse-websocket/docs/output/tests.txt
Normal file
16
sse-websocket/docs/output/tests.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 25.88 s -- in com.ankurm.ssews.StompSizeLimitTunedBufferTest
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.620 s -- in com.ankurm.ssews.AsyncTimeoutUnsetTest
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.044 s -- in com.ankurm.ssews.SseWireFormatTest
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.042 s -- in com.ankurm.ssews.WebSocketHandshakeOriginTest
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.416 s -- in com.ankurm.ssews.AsyncTimeoutPropertyTest
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.144 s -- in com.ankurm.ssews.SseDisconnectTest
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.342 s -- in com.ankurm.ssews.SseConcurrencyTest
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.023 s -- in com.ankurm.ssews.SseTimeoutTest
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.146 s -- in com.ankurm.ssews.StompChatTest
|
||||
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.647 s -- in com.ankurm.ssews.StompRoutingTest
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 13.52 s -- in com.ankurm.ssews.StompSizeLimitSpringLimitTest
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.040 s -- in com.ankurm.ssews.SseDataConversionTest
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.042 s -- in com.ankurm.ssews.WebSocketContainerRaisedTest
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 22.80 s -- in com.ankurm.ssews.StompSizeLimitDefaultBufferTest
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.143 s -- in com.ankurm.ssews.StompUserDestinationTest
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.025 s -- in com.ankurm.ssews.WebSocketContainerDefaultsTest
|
||||
36
sse-websocket/docs/output/websocket-size-limits.txt
Normal file
36
sse-websocket/docs/output/websocket-size-limits.txt
Normal file
@@ -0,0 +1,36 @@
|
||||
The limit that fires first is the servlet container's WebSocket buffer, not Spring's
|
||||
messageSizeLimit. 'body' below is the length of the JSON string field; the STOMP frame
|
||||
carries about 90 bytes of headers on top of it.
|
||||
|
||||
=== container buffer: Tomcat default (8192, see /diag) ===
|
||||
8 KB body -> delivered, body length 8192
|
||||
16 KB body -> delivered, body length 16384
|
||||
60 KB body -> NOT delivered, connection dropped
|
||||
largest body delivered : 16459 bytes
|
||||
smallest body rejected : 16522 bytes
|
||||
|
||||
=== container buffer set to 16384 ===
|
||||
largest body delivered : 16446 bytes
|
||||
smallest body rejected : 16496 bytes
|
||||
=== container buffer set to 32768 ===
|
||||
largest body delivered : 32615 bytes
|
||||
smallest body rejected : 32665 bytes
|
||||
=== container buffer set to 65536 ===
|
||||
largest body delivered : 65350 bytes
|
||||
smallest body rejected : 65400 bytes
|
||||
|
||||
=== bigframes profile: container buffer 256 KB, Spring limit 64 KB ===
|
||||
16 KB body -> delivered, body length 16384
|
||||
60 KB body -> delivered, body length 61440
|
||||
desti..], byteCount=82068, last=true] in session 1655473b-76fe-4489-99d6-e3f8200b41b4. Sending STOMP ERROR to client.
|
||||
org.springframework.messaging.simp.stomp.StompConversionException: The configured STOMP buffer size limit of 65536 bytes has been exceeded
|
||||
80 KB body -> NOT delivered, connection dropped
|
||||
desti..], byteCount=204949, last=true] in session 8dcce90c-48db-43a5-8d32-659da1fe32d8. Sending STOMP ERROR to client.
|
||||
org.springframework.messaging.simp.stomp.StompConversionException: The configured STOMP buffer size limit of 65536 bytes has been exceeded
|
||||
200 KB body -> NOT delivered, connection dropped
|
||||
|
||||
Spring's own limit, once the container is out of the way:
|
||||
org.springframework.messaging.simp.stomp.StompConversionException: The configured STOMP buffer size limit of 65536 bytes has been exceeded
|
||||
|
||||
"ws.container.defaultMaxTextMessageBufferSize":262144
|
||||
"ws.container.defaultMaxTextMessageBufferSize":8192
|
||||
Reference in New Issue
Block a user