A WebObjects application answers requests. Sometimes you want the other direction: a page that updates when something happens on the server, without the browser asking. A progress bar during a long job, a notification when someone else changes the data you're looking at, a live figure on a dashboard.

wo-adaptor-jetty supports two ways of doing that. Server-sent events are the one to reach for: a normal HTTP response that never ends, which the server keeps writing to. WebSockets are there when you need the browser to talk back over the same connection. This guide covers both, starting with events, because most things that look like they need a WebSocket don't.

This needs wo-adaptor-jetty. Neither works with WebObjects' classic adaptor: it has no way to send a response of unknown length, so an event stream would be answered with an empty body and the events would never leave the server. Run your application with -WOAdaptor WOAdaptorJetty.

1. Which one do you want?

Server-sent events carry text from the server to the browser over one long-lived HTTP response. The browser reconnects by itself when the connection drops, and the whole thing is ordinary HTTP: it goes through proxies, works with your existing sessions and cookies, and can be tested with curl. The browser cannot send anything back on that connection, but it can always make a normal request, which is what most applications want anyway.

WebSockets give you a two-way channel after an upgrade handshake. That is genuinely better for a chat, a collaborative editor, or anything where the browser speaks constantly. It costs you: the connection lives outside the request cycle, so WebObjects sessions, routes and components aren't involved, and every proxy between you and the client has to be willing to tunnel it.

A useful rule: if the browser only ever needs to hear about things, use events. Reach for a socket when it needs to speak often enough that a request per message is wasteful.

2. The dependency

Both live in a separate module, so an application that doesn't push carries none of it:

<dependency>
    <groupId>is.rebbi</groupId>
    <artifactId>wo-adaptor-jetty-push</artifactId>
    <version>0.10.0</version>
</dependency>

It is released together with the adaptor and used at the same version. Adding it to the classpath is all that's needed: the adaptor finds it and enables WebSocket upgrades on its own server, with nothing to configure. Server-sent events need nothing beyond the classes described below.

The module was called wo-adaptor-jetty-websocket up to and including 0.9.0. If you're upgrading from that, change the artifactId; the package names are unchanged.

3. A first event stream

An SSEStream is an open-ended response. You create it, return its response from any action, and keep sending to it afterwards — the request has finished, but the response is still open:

import com.webobjects.appserver.sse.SSEStream;

public WOActionResults progressAction() {
    final SSEStream stream = new SSEStream();

    Thread.startVirtualThread( () -> {
        try {
            for( int step = 1; step <= 10 && stream.isOpen(); step++ ) {
                stream.send( "progress", String.valueOf( step * 10 ) );
                Thread.sleep( 500 );
            }
        }
        catch( InterruptedException e ) {
            Thread.currentThread().interrupt();
        }
        finally {
            stream.close();
        }
    } );

    return stream.response();
}

The action returns immediately. WebObjects is done with the request; the adaptor holds the response open and writes each event as it is sent, and the stream ends when close() is called or the client goes away.

On the page:

const events = new EventSource('/progress');

events.addEventListener('progress', (e) => {
    document.getElementById('bar').style.width = e.data + '%';
});

That is the whole mechanism. Everything below is detail around it.

4. Sending events

send comes in three forms, and none of them block — events are queued, so you can send from any thread, including one holding a database lock you'd rather not hold while writing to a socket:

stream.send( "some data" );                      // unnamed: arrives as "message" on the client
stream.send( "userJoined", "hugi" );             // named: addEventListener( "userJoined", … )
stream.send( "tick", "09:41:12", "evt-1041" );   // named, with an id

stream.comment( "still here" );                  // a comment line: clients ignore it

Data is text. Multi-line strings are sent as multiple data lines, as the format requires, and arrive as one string with the newlines intact. For structured data, serialise it yourself:

stream.send( "orderUpdated", new Gson().toJson( order ) );

The id matters if you care about resumption. A browser remembers the last id it saw and sends it back as the Last-Event-ID header when it reconnects, so an application that numbers its events can carry on where the client left off:

final String lastSeen = request().headerForKey( "last-event-id" );

if( lastSeen != null ) {
    for( Event missed : eventsSince( lastSeen ) ) {
        stream.send( "update", missed.json(), missed.id() );
    }
}

5. When the client goes away

A stream ends in one of two ways: you close it, or the client disconnects and the adaptor closes it for you. Either way isOpen() turns false and any registered listener runs:

stream.onClose( () -> {
    subscribers.remove( stream );
    logger.info( "Client left, {} remaining", subscribers.size() );
} );

Sending to a closed stream is a silent no-op, so a broadcaster never has to check first, and a race between a client leaving and an event going out is harmless.

Detection is not instant: the server discovers the client is gone when it next tries to write. A stream that sends something every few seconds notices within seconds; an idle one notices at the next keep-alive, which is every 30 seconds by default. If you keep server-side state per connection, that's the window you're reclaiming it in.

6. Broadcasting to everyone

A single stream is one client. SSEHub is a set of them, and streams leave it automatically when they close, so it only ever holds live connections:

import com.webobjects.appserver.sse.SSEHub;

public class OrderFeed {

    private static final SSEHub SUBSCRIBERS = new SSEHub();

    /** The action a page subscribes through */
    public static WOActionResults subscribe() {
        return SUBSCRIBERS.open().response();
    }

    /** Called from wherever orders actually change */
    public static void orderChanged( final Order order ) {
        SUBSCRIBERS.broadcast( "orderUpdated", new Gson().toJson( order ) );
    }
}

open() creates a stream, adds it to the hub and hands it back, so you can send something to just that client before returning its response — the current state, say, so a page that has just connected doesn't sit empty until the next change:

public static WOActionResults subscribe() {
    final SSEStream stream = SUBSCRIBERS.open();
    stream.send( "snapshot", new Gson().toJson( currentOrders() ) );
    return stream.response();
}

size() tells you how many are connected, which is often worth broadcasting itself, and closeAll() ends every stream, for a clean application shutdown.

One hub per feed, held somewhere that outlives a request — a static field or an application-level object. Not on a session or a component: those come and go, and a hub on one would lose its subscribers.

7. Keep-alives and timeouts

An idle connection looks dead to everything between the server and the browser. SSEStream therefore writes a comment line every 30 seconds while nothing else is going out, which keeps proxies and the adaptor's own idle timeout from closing it. You can change the interval:

new SSEStream( Duration.ofSeconds( 15 ) );
SUBSCRIBERS.open( Duration.ofSeconds( 15 ) );

Keep it below the adaptor's connector idle timeout, which defaults to 600 seconds and is set with -DJettyConnectorIdleTimeoutSeconds, and below the idle timeout of any proxy in front. The default of 30 seconds is well inside both.

8. Configuration a long-lived response needs

One adaptor setting matters here. If you limit how many requests the application handles at once with JettyMaxConcurrentRequests, remember that an open event stream is a request that never finishes and holds its slot for as long as the client stays. A handful of subscribers would use up a small limit and leave nothing for ordinary pages.

Exclude the paths your streams live on:

-DJettyMaxConcurrentRequests=50
-DJettyQoSExcludedPaths=/events/*,/feed/*

The value is a comma-separated list of Jetty path specs; requests matching them bypass the limit entirely. If you don't set JettyMaxConcurrentRequests at all there is no limit and nothing to exclude.

Nothing else needs configuring. The adaptor sends the right content type, no content length, and Cache-Control: no-store, and streams the response with chunked transfer encoding until it ends.

9. Two traps on the client side

Both cost an afternoon to find and a minute to avoid.

Firefox coalesces concurrent requests to an identical URL. It dispatches the first and holds the rest until it completes — which for an event stream is never. Open the same page in two tabs and the second one hangs, with no error anywhere: the request never reaches the server. No response header prevents this; the fix is to make each connection's URL unique:

const events = new EventSource('/events?t=' + Date.now());

Do it from the start. It costs nothing, and the symptom — works in Chrome, second tab blank in Firefox, nothing in either log — is genuinely hard to read.

Browsers limit connections per origin over HTTP/1.1, traditionally six. Each open stream is one of them, so a page holding an event stream has five left for everything else, and six tabs of the same application can wedge it. This disappears over HTTP/2, which multiplexes: if your production front end serves HTTP/2, as modulo does, only development against the application's own port is affected. Worth knowing before you open a stream per widget.

10. Through a proxy

An event stream only works end to end if everything in the middle passes it through unbuffered. A proxy that buffers responses will hold your events and deliver them in a lump, or not at all.

modulo passes streams through as they arrive; nothing to configure. nginx buffers by default, and the adaptor already sends the X-Accel-Buffering: no header it honours, so that case is handled too. Apache with mod_proxy generally streams, but if events arrive in batches, buffering is the first thing to check.

curl is the quickest way to tell. If the events appear one at a time, it works:

curl -N https://www.example.com/events

Test through the front end, not just against the application's port — buffering is a property of the path, not the application.

11. WebSockets

WebSocket support is experimental: it works and is in use, but the API may still change. Endpoints are registered by path at startup, and a handler instance is created per connection:

import com.webobjects.appserver.websocket.WOWebSocketRegistry;

public Application() {
    WOWebSocketRegistry.register( "/ws/chat", ChatHandler.class );
}

The handler is where the connection lives:

import com.webobjects.appserver.websocket.WOWebSocketHandler;
import com.webobjects.appserver.websocket.WOWebSocketSession;

public class ChatHandler extends WOWebSocketHandler {

    @Override
    public void onConnect( WOWebSocketSession session, WORequest request ) {
        // The HTTP request that opened the connection: cookies, headers, form values.
        // This is where you authenticate, since the socket itself carries no session.
        final String wosid = request.cookieValueForKey( "wosid" );
        session.setAttribute( "user", userForSession( wosid ) );

        try {
            session.sendText( "welcome" );
        }
        catch( IOException e ) {
            logger.error( "Failed to greet client", e );
        }
    }

    @Override
    public void onTextMessage( WOWebSocketSession session, String message ) {
        broadcast( session.getAttribute( "user" ) + ": " + message );
    }

    @Override
    public void onClose( WOWebSocketSession session, int statusCode, String reason ) {
        connections.remove( session );
    }
}

The session sends (sendText, sendBinary), closes, reports isOpen() and the remote address, and carries per-connection attributes. Keeping the open sessions in a set of your own is how you broadcast; there is no hub for sockets the way there is for streams.

The important constraint is in onConnect: a WebSocket is not a WebObjects request. There is no session, no component, no route. The upgrade request is handed to you so you can identify the client from its cookies or headers, and after that the connection is yours to manage. Anything a WebObjects session would have held, you hold in the session's attributes or in your own structures.

From the browser, with the scheme matched to how the page was loaded:

const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat';
const socket = new WebSocket(url);

socket.onmessage = (e) => console.log(e.data);
socket.onopen = () => socket.send('hello');

The idle timeout is infinite by default; set -DJettyWebSocketIdleTimeout=300 to have dead connections reaped after five minutes, and send something from the client well inside that so live ones aren't. WOWebSocketHandler has a built-in heartbeat for the server side of that.

12. In practice

  • Prefer events to sockets unless the browser genuinely needs to speak. Events are plain HTTP, and everything you already know about requests still applies.
  • Hold hubs statically, never on a session or a component.
  • Send something meaningful on connect. A subscriber that waits for the next change looks broken until something happens.
  • Make the stream URL unique per connection, for Firefox, from the first line of client code you write.
  • Exclude stream paths from JettyMaxConcurrentRequests if you use it at all.
  • Don't push what you can't afford to lose. A client that reconnects has missed whatever happened while it was away unless you gave your events ids and can replay them.
  • Test through the front end with curl -N, and in more than one browser.

The classes are three: SSEStream, SSEHub and, for sockets, WOWebSocketRegistry with WOWebSocketHandler. The wo-adaptor-jetty repository has them, and wonder-slim's AjaxPlayground has a working page for each: a plain EventSource clock, and a page driven entirely by pushed updates.