WebSocket#
Writing one#
public class ChatHandler implements WsHandler {
@Override
public boolean onUpgrade (WsSession session) {
// 認証はここで通す。Cookie が読めるのはこの時点だけ。断ると 403
return "secret".equals(session.cookie("token"));
}
@Override
public void onMessage (WsContext context, String message) {
context.session().send("echo: " + message);
}
}
Registering it#
ws("/ws/posts", PostFeedHandler::new);
You write it in the same place as your HTTP routes. It shows up in the startup log too, as WS /ws/posts.
What you pass is a constructor reference, not an instance. One handler is created per connection.
The order they are called in#
| Method | When |
|---|---|
onUpgrade(WsSession) |
Before the upgrade from HTTP. Return false and you get a 403 |
onOpen(WsContext) |
Once the connection is up |
onMessage(WsContext, String) |
Text arrived |
onBinary(WsContext, byte[]) |
Binary arrived |
onClose(WsContext, int, String) |
It closed |
onError(WsContext, Throwable) |
Something threw |
Every one of them has a default implementation, so you write only the ones you need.
Authenticate in onUpgrade#
onUpgrade is the only point at which you can read a cookie.
After the upgrade there are no HTTP headers any more.
@Override
public boolean onUpgrade (WsSession session) {
return "secret".equals(session.cookie("token"));
}
Return false and it is turned away with a 403.
Note
Connections from pages on other sites are refused before onUpgrade.
CORS does not apply to WebSocket, and browsers send cookies on cross-site connections too.
The server (helidon) compares Origin with Host and answers 403 when they differ, so cookie authentication cannot be hijacked by another site.
Requests without Origin (non-browser clients) go through.
Behind a proxy, make the proxy pass Host through unchanged (proxy_set_header Host $host;); if it rewrites it, connections from your own site are refused too.
A Context per message#
One Context is created for each message.
It is treated exactly like a request, or like a single batch execution.
So inside onMessage you use the DB and the session as normal.
When the message is done, the DB connection goes back, even though the connection stays up.
public void onMessage (WsContext context, String message) {
Data request = Data.fromJsonString(message);
switch (request.getStringOptional("command")) {
case "latest" -> context.session().send(new Data()
.putData("type", "latest")
.putData("posts", titles()));
case "subscribe" -> {
SUBSCRIBERS.add(context.session());
context.session().send(new Data().putData("type", "subscribed"));
}
/*
* 何が使えるのかを返す。
* 黙って無視すると、クライアント側は
* 「届いていないのか、コマンドが違うのか」が分からない。
*/
default -> context.session().send(new Data()
.putData("type", "error")
.putData("message", "知らないコマンドです")
.putData("commands", List.of("latest", "subscribe")));
}
}
Do not silently drop a command you do not recognise. From the client side there is no way to tell "did it not arrive, or was the command wrong".
Sending to other connections#
You keep the connected sessions yourself.
private static final Set<WsSession> SUBSCRIBERS = ConcurrentHashMap.newKeySet();
public static void notifyNewPost (String title) {
Data event = new Data().putData("type", "new_post").putData("title", title);
// send() returning false means that connection is already gone. Drop it
SUBSCRIBERS.removeIf(session -> !session.send(event));
}
If you run more than one server, fanning out this way will not reach anyone connected to a different one. Put something like Redis pub/sub in between. jimble does not go that far.
SSE or WebSocket#
If the client has nothing to send, SSE is the easier one. No proxy configuration, and the browser handles the reconnecting for you.