Reqs-AI pushes live updates (e.g. a discovery capture session) over STOMP-over-WebSocket. Business
code never touches the messaging API — it depends on the RealtimeNotifier port in
shared.application.notification; the StompRealtimeNotifier adapter does the rest. The broker is
switchable (in-memory for one instance, external relay for many) by configuration only.
┌─ bounded context (e.g. discovery) ─┐ ┌─ shared/infrastructure ───────────────┐
│ inject RealtimeNotifier │ │ StompRealtimeNotifier (adapter) │
│ notifier.send("/topic/…", payload)│ ───▶ │ → SimpMessagingTemplate │
└────────────────────────────────────┘ │ → broker: SIMPLE (in-mem) | RELAY │
depends ONLY on the port └────────────────────────────────────────┘
Inject the port and send. Payloads are serialized to JSON automatically; a failed send is logged, never thrown, so it can't break the caller's transaction.
@Service
@RequiredArgsConstructor
class CaptureSessionService {
private final RealtimeNotifier notifier;
void onRequirementAdded(UUID sessionId, RequirementView req) {
notifier.send("/topic/sessions/" + sessionId, req); // broadcast to subscribers
notifier.sendToUser(userId.toString(), "/queue/alerts", a); // one user's private queue
notifier.broadcast("sessions/" + sessionId, req); // shortcut for /topic/<x>
}
}Only needed if clients send messages (not just subscribe). A @Controller with @MessageMapping:
@Controller
class TypingController {
@MessageMapping("/sessions/{id}/typing") // client sends to /app/sessions/{id}/typing
@SendTo("/topic/sessions/{id}") // re-broadcast to subscribers
TypingEvent typing(@DestinationVariable UUID id, Principal user) {
return new TypingEvent(id, user.getName());
}
}The handshake hits /ws (reqsai.websocket.endpoint). Authentication happens on the STOMP CONNECT
frame, not the HTTP handshake: send the JWT as a native Authorization header.
import { Client } from '@stomp/stompjs';
const client = new Client({
brokerURL: 'ws://localhost:8080/ws',
connectHeaders: { Authorization: `Bearer ${accessToken}` }, // verified on CONNECT
});
client.onConnect = () => {
client.subscribe('/topic/sessions/123', (msg) => console.log(JSON.parse(msg.body)));
client.subscribe('/user/queue/alerts', (msg) => console.log('private', JSON.parse(msg.body)));
};
client.activate();- The HTTP handshake to
/ws/**is permitted inSecurityConfiguration(no token yet). - The client sends
Authorization: Bearer <jwt>as a native STOMP header on CONNECT. StompAuthChannelInterceptorverifies it with the sameTokenVerifieras the HTTP filter and binds the userPrincipalto the CONNECT frame's accessor, and — separately — stashesuserId/orgIdin the STOMP session attributes.- CONNECT with no token → anonymous; with an invalid token → rejected (the verifier throws).
The
Principaldoes not persist past CONNECT. Empirically (verified against a running instance), a later frame'saccessor.getUser()on the same STOMP session comes backnull— only the session attributes carry over to every subsequent frame. Anything that needs the caller's identity outside the CONNECT handler itself (a session-lifecycle listener, a future@MessageMappinghandler) must readStompAuthChannelInterceptor.USER_ID_ATTRIBUTE/ORG_ID_ATTRIBUTEfromaccessor.getSessionAttributes(), notaccessor.getUser(). This is why presence resolves identity this way (see below).sendToUseris expected to be unaffected — Spring resolves it via a username registry populated from the CONNECT frame itself, not by re-readingaccessor.getUser()on later frames — but it has no caller in this codebase yet, so that has not been directly exercised.
| Prefix | Direction | Use |
|---|---|---|
/app |
client → server | routed to your @MessageMapping handlers |
/topic |
server → clients | broadcast (many subscribers) |
/user |
server → one user | per-user queue (sendToUser, resolved per principal) |
Discovery tracks who is currently viewing a live session and broadcasts the roster so every participant sees the others. It is built entirely on the STOMP lifecycle — there is no extra subscription and no client→server message:
- Signal: a client's SUBSCRIBE to
/topic/sessions/{id}is "I am present".SessionPresenceTrackerlistens toSessionSubscribeEvent/SessionUnsubscribeEvent/SessionDisconnectEvent. - State:
SessionPresenceRegistryholds, per session, which connections are present (a user across two tabs counts once). It is in-process — not Redis — and, like theSIMPLEbroker, per-JVM. - Broadcast: on any real roster change the tracker sends a
PRESENCE_STATEmessage (SessionPresenceMessage: full participant snapshot +count) back on the samesessions/{id}topic. The client keeps its one subscription and switches ontype(see the single-topic pattern below). - Identity: the CONNECT interceptor stashes
userIdandorgIdin the STOMP session attributes (see the callout above — not the framePrincipal); the tracker resolves eachuserIdto a display name viaWorkspaceModuleApi.findMemberDisplayName(Caffeine-cached) and a deterministicavatarUrl(/api/users/{userId}/avatar).
Multi-instance caveat: because the registry is per-JVM (same as the
SIMPLEbroker), a global roster across several ECS tasks needs the sharedRELAYbroker's state — acceptable at single-instance scale.
The default SIMPLE broker is in-memory and only knows connections on the local JVM. With more
than one instance (several ECS Fargate tasks), a broadcast from instance A never reaches clients on
instance B. For multi-instance production, switch to an external STOMP broker — no code change:
reqsai:
websocket:
broker:
mode: RELAY # default is SIMPLE
host: ${MQ_HOST} # Amazon MQ (RabbitMQ/ActiveMQ), or self-hosted
port: 61613
login: ${MQ_USER}
passcode: ${MQ_PASS}Every instance then relays through the shared broker and fan-out works across all of them. RELAY
requires io.projectreactor.netty:reactor-netty on the classpath. See
ADR-0007.
| Property | Default | Notes |
|---|---|---|
endpoint |
/ws |
STOMP handshake path |
allowed-origins |
http://localhost:4200 |
CORS for the handshake |
topic-prefix |
/topic |
server → client broadcasts |
application-prefix |
/app |
client → server (@MessageMapping) |
user-prefix |
/user |
per-user destinations |
enable-sock-js |
false |
SockJS fallback (native WS only by default) |
broker.mode |
SIMPLE |
SIMPLE (in-memory) or RELAY (external broker) |
Mint a token with TestJwtFactory and set it as the CONNECT Authorization header to test the
interceptor end-to-end (same throwaway test key as the HTTP filter — see
CONTRIBUTING).