Using Server-Sent Events on Domino
Tue Mar 30 08:57:20 EDT 2021
Though Domino's HTTP stack infamously doesn't support WebSocket, WebSocket isn't the only game in town when it comes to getting push-type information to HTTP clients. HTML5 also brought with it the less-famous Server-Sent Events standard, which is basically half of WebSocket: it allows the server to push events to the client, but it's still a one-way communication channel.
The Standard
The technique that SSE uses is almost ludicrously simple: the client makes a request and the server replies that it will provide text/event-stream
content and keeps the connection open. Then, it starts emitting events delimited by blank lines:
1 2 3 4 5 6 7 8 9 10 | HTTP/1.1 200 OK Content-Type: text/event-stream;charset=UTF-8 event: timeline data: hello event: timeline data: hello |
Unlike WebSocket, there's no Upgrade
header, no two-way communication, and thereby no special requirements on the server. It's so simple that you don't even really need a server-side library to use it, though it still helps.
In Practice
I've found that, though SSE is intentionally far less capable than WebSocket, it actually provides what I want in almost all cases: the client can receive messages instantaneously from the server, while the server can receive messages from the client by traditional means like POST requests. Though this is less efficient and flexible than WebSocket, it suits perfectly the needs of apps like server monitors, chat rooms, and so forth.
Using SSE on Domino
JAX-RS, the Java REST service framework, provides a mechanism for working with server-sent events pretty nicely. Baeldung, as usual, has a splendid tutorial covering the API, and a chunk of what I say here will be essentially rehashing that.
However, though Domino ships with JAX-RS by way of the ExtLib, the library only implements JAX-RS 1.x, which predates SSE support. Fortunately, newer JAX-RS implementations work pretty well on Domino, as long as you bring them in in a compatible way. In my XPages Jakarta EE Support project, I did this by way of RESTEasy, and there did the legwork to make it work in Domino's OSGi environment. For our example today, though, I'm going to skip that and build a small webapp using the com.ibm.pvc.webcontainer.application
extension point. In theory, this should also work XPages-side with my project, though I haven't tested that; it might require messing with the Servlet response cache.
The Example
I've uploaded my example to GitHub, so the code is available there. I've aimed to make it pretty simple, though there's always some extra scaffolding to get this stuff working on Domino. The bulk of the "pom.xml" file is devoted to two main things: packaging an app as an OSGi bundle (with RESTEasy embedded) and generating an update site with site.xml to import into Domino.
Server Side
The real work happens in TimeStreamResource
, the JAX-RS resource that manages client connections and also, in this case, happens to emit the messages as well.
This resource, when constructed, spawns two threads. The first one monitors a BlockingQueue
for new messages and passes them along to the SseBroadcaster
:
1 2 3 4 5 6 7 8 9 10 11 12 13 | try { String message; while((message = messageQueue.take()) != null) { // The producer below may send a message before setSse is called the first time if(this.sseBroadcaster != null) { this.sseBroadcaster.broadcast(this.sse.newEvent("timeline", message)); //$NON-NLS-1$ } } } catch(InterruptedException e) { // Then we're shutting down } finally { this.sseBroadcaster.close(); } |
Here, I'm using the Sse#newEvent
convenience method to send a basic text message. In practice, you'll likely want to use the builder you get from Sse#newEventBuilder
to construct more-complicated events with IDs and structured data types (usually JSON).
A BlockingQueue
implementation (such as LinkedBlockingDeque
) is ideal for this task, as it provides a simple API to add objects to the queue and then wait for new ones to arrive.
The second one emits a new message every 10 seconds. This is just for the example's sake, and would normally be actually looking something up or would itself be a listener for events it would like to broadcast.
1 2 3 4 5 6 7 8 9 10 11 | try { while(true) { String eventContent = "- At the tone, the Domino time will be " + OffsetDateTime.now(); messageQueue.offer(eventContent); // Note: any sleeping should be short enough that it doesn't block HTTP restart TimeUnit.SECONDS.sleep(10); } } catch(InterruptedException e) { // Then we're shutting down } |
Browsers can register as listeners just by issuing a GET request to the API endpoint:
1 2 3 4 5 | @GET @Produces(MediaType.SERVER_SENT_EVENTS) public void get(@Context SseEventSink sseEventSink) { this.sseBroadcaster.register(sseEventSink); } |
That will register them as an available listener when broadcast events are sent out.
Additionally, to simulate something like a chat room, I added a POST endpoint to send new messages beyond the periodic ten-second broadcast:
1 2 3 4 5 6 | @POST @Produces(MediaType.TEXT_PLAIN) public String sendMessage(String message) throws InterruptedException { messageQueue.offer(message); return "Received message"; } |
That's really what there is to it as far as "business logic" goes. There's some scaffolding in the Servlet implementation to get RestEasy working nicely and manage the ExecutorService
and the obligatory "plugin.xml" to register the app with Domino and "web.xml" to account for Domino's old Servlet spec, but that's about it.
Client Side
On the client side, everything you need is built into every modern browser. In fact, the bulk of "index.html" is CSS and basic HTML. The JavaScript involved in blessedly slight:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | function sendMessage() { const cmd = document.getElementById("message").value; document.getElementById("message").value = ""; fetch("api/time", { method: "POST", body: cmd }); return false; } function appendLogLine(line) { const output = document.getElementById("output"); output.innerText += line + "\n"; output.scrollTop = output.scrollHeight; } function subscribe() { const eventSource = new EventSource("api/time"); eventSource.addEventListener("timeline", (event) => { appendLogLine(event.data); }); eventSource.onerror = function (err) { console.error("EventSource failed:", err); }; } window.addEventListener("load", () => subscribe()); |
The EventSource
object is the core of it and is a standard browser component. You give it a path to watch and then listen for events and errors. fetch
is also standard and is a much-nicer API for dealing with HTTP requests. In a real app, things might get a bit more complicated if you want to pass along credentials and the like, but this is really it.
Gotchas
The biggest thing to keep in mind when working with this is that you have to be very careful to not block Domino's HTTP task from restarting. If you don't keep everything in an ExecutorService
and account for InterruptedException
s as I do here, you're highly likely to run into a situation where a thread will keep chugging along indefinitely, leading to the dreaded "waiting for session to finish" loop. The ExecutorService
's shutdownNow
method helps you manage this - as long as your threads have escape hatches for the InterruptedException
they'll receive, you should be good.
I also, admittedly, have not yet tested this at scale. I've tried it out here and there for clients, but haven't pulled the trigger on actually shipping anything with it. It should work fine, since it's using standard JAX-RS stuff, but there's always the chance that, say, the broadcaster registry will fill up with never-ending requests and will eventually bloat up. The stack should handle that properly, but you never know.
Beyond any worries about the web container, it's also just a minefield of potential threading and duplicated-work trouble. For example, when I first wrote the example, I found that messages weren't shared, and then that the time messages could get doubled up. That's because JAX-RS, by default, creates a new instance of the resource class for each request. Moving the declaration from the Application
class's getClasses()
method (which creates new objects) to getSingletons()
(which reuses single objects) fixed the first problem. After that, I found that the setSse
method was called multiple times even for the singleton, and so I moved the thread spawning to the constructor to ensure that they're only launched once.
Once you have the threading sorted out, though, this ends up being a pretty-practical path to accomplishing the bulk of what you would normally do with WebSocket, even with an aging HTTP stack like Domino's.