WebSocket

spider-silk-jetty-websocket maps WebSocket endpoints alongside an app’s HTTP routes. It is Jetty-only by construction, which is why it is a module with a name rather than a method on App. A WebSocket is a protocol upgrade, and an upgrade leaves servlet dispatch; see why SSE is in core and WebSocket is not. The module needs no change to core, because it plugs into JettyServer’s existing `customizeServer hook.

Installation

dependencies {
    implementation 'net.benelog.spidersilk:spider-silk-jetty-websocket:1.1.0'
}

It brings jetty-websocket-jetty-server, Jetty’s server-side WebSocket at the Handler level, pinned to the same Jetty version core uses. Choosing it over the ee10 artifact of the same name is deliberate. The ee10 one is the servlet-container integration, and it arrives with annotation scanning, ASM, CDI, and JNDI in tow, none of which this framework wants near it.

Mapping a path

new App()
        .get("/", req -> WebResponse.text("hi"))
        .server((app, port) -> new JettyServer(app).port(port)
                .customizeServer(new WebSockets()
                        .at("/echo", (request, response) -> new EchoSocket())))
        .start(8080);

The upgrade handler is inserted ahead of the servlet, so a request whose path matches a mapping is upgraded and everything else falls through to the app unchanged. Paths are relative to the server’s context path, the same as a route is.

WebSockets is a Consumer<Server>, which is exactly what customizeServer takes. The hook is customizeServer rather than customizeContext because Jetty’s WebSocket container is built from the `Server’s buffer pool and executor, and the context is not linked to a server until after the context customizers have run. The diagram shows where the upgrade handler lands in Jetty’s handler chain, and which requests reach the servlet behind it:

JettyServer.start builds the Server
Figure 1. Where the upgrade handler sits

The endpoint

WebSocketHandler is one connection, from the upgrade to the close. The factory builds one per connection, so an implementation may keep per-connection state in fields: the callbacks are never called concurrently for the same connection. That holds for the end of a connection too: an idle timeout or a failed write raises onError and onClose on another Jetty thread, and they wait for a running onText or onBinary to return.

final class EchoSocket implements WebSocketHandler {

    @Override
    public void onOpen(Session session) {
        session.sendText("welcome", Callback.NOOP);
    }

    @Override
    public void onText(Session session, String message) {
        session.sendText(message, Callback.NOOP);
    }
}

Every method has a do-nothing default (onOpen, onText, onBinary, onClose, and onError), so an implementation overrides only what it answers. The Session is passed to each one rather than handed out once, so nothing has to be remembered in a field to reply.

Session is Jetty’s own and is not wrapped. The module is tied to Jetty by name, so hiding Jetty behind a facade would buy portability the name has already ruled out. sendText and sendBinary are asynchronous and take a Callback. Callback.NOOP is the fire-and-forget form, and passing a real callback is how a handler notices a send failure. A handler never calls session.demand(), because the module’s endpoint is auto-demanding: the next message is asked for as soon as the callback returns.

The buffer passed to onBinary is Jetty’s, and it goes back to the pool as soon as the method returns. Copy anything that has to outlive the call.

One connection, from the upgrade request to the close, runs like this:

The upgrade handler calls the factory while the upgrade is still an HTTP request; a returned handler is wrapped in a SessionListener and the handshake is answered with 101
Figure 2. One WebSocket connection

Refusing an upgrade

The factory runs before the handshake is answered, which is where the upgrade is still an ordinary HTTP request: headers, cookies, and the query string are all readable.

new WebSockets().at("/chat", (request, response) -> {
    if (!authorized(request)) {
        return null; // 403, and the handshake is answered
    }
    if (request.hasSubProtocol("chat")) {
        response.setAcceptedSubProtocol("chat");
    }
    return new ChatSocket(rooms);
});

Returning null refuses the upgrade with 403, or with the status the factory set on the response if that is an error status. Jetty’s own contract leaves a refused upgrade unanswered. The creator is expected to write the response and complete the callback itself, and a creator that forgets leaves the client waiting on a handshake that never comes. The module writes that error response itself, which is one of the few things it does beyond mapping a path.

Paths

A path is Jetty’s spec syntax, not core’s router syntax: an exact path, /rooms/ for a prefix, or .ws for a suffix. There is no {name} segment. A variable path is a prefix mapping with Request.getPathInContext(request) read in the factory:

new WebSockets().at("/rooms/*", (request, response) ->
        new ChatSocket(Request.getPathInContext(request).substring("/rooms/".length())));

A spec Jetty does not recognise fails in at(…​), where it was written, rather than at startup.

Tuning

new WebSockets()
        .at("/echo", (request, response) -> new EchoSocket())
        .idleTimeout(Duration.ofMinutes(5))
        .maxTextMessageSize(64 * 1024)
        .maxBinaryMessageSize(1024 * 1024)
        .customizeContainer(container -> container.setAutoFragment(false));

Jetty closes a connection idle for 30 seconds by default. A socket that stays open through quiet periods survives that timeout only if the two ends ping each other. customizeContainer runs against Jetty’s ServerWebSocketContainer on every start, and reaches the settings that WebSockets has no method for: frame size, buffer sizes, and session listeners.

What does not reach a socket

An upgrade leaves servlet dispatch for good, so none of the framework follows it:

  • the router does not, and app.routes() does not list a socket,

  • beforeRequest, beforeRoute, afterRoute, and responseFilter filters do not run,

  • statusPage(status, …​) does not cover it,

  • the request logger never sees the connection, the upgrade included, because the upgrade handler answers ahead of AppServlet and the servlet never runs,

  • WebTest cannot reach it, since its client speaks plain HTTP and has no way to open a socket.

That list is the whole reason WebSocket is not in core: core would be publishing an API its own features silently do not cover. The module’s tests show the same limit from the other side. They start a real Jetty on a free port and talk to it with the JDK’s own java.net.http.WebSocket client, because there is no harness that could stand in.

app.stop() closes the sockets still open. It does not hold the graceful drain open waiting for them, the way an SSE stream in flight would be waited on.

Reflection

Jetty binds an endpoint’s callbacks by looking its methods up and taking a MethodHandle to each, cached per endpoint class. Every connection this module opens is the same class, SessionListener, the module’s own adapter, so that is the one class Jetty ever looks up. A WebSocketHandler an application writes is reached through an interface call and is never reflected over, whatever its shape, which is the same confinement jte’s generated classes get.