Add the sse-websocket module
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user