Gumdrop

Gumdrop WebSocket Service & Client

Gumdrop provides a standalone WebSocket service and client, built on top of the HTTP transport but presenting a clean, message-oriented programming model. WebSocket connections begin as HTTP upgrade requests but, once established, operate as raw bidirectional message channels with no further HTTP semantics.

The same WebSocketEventHandler interface is used on both the server and client side, making it straightforward to write applications that operate in either role or both.

Contents

Overview

The WebSocket Protocol (RFC 6455) provides full-duplex communication over a single TCP connection. Unlike HTTP’s request–response model, either side can send messages at any time once the connection is open. This makes it ideal for real-time applications such as chat, live feeds, collaborative editing, and IoT telemetry.

Gumdrop’s WebSocket implementation is structured around a clean separation of concerns:

WebSocket Service

To create a WebSocket server, extend WebSocketService and implement the createConnectionHandler method. This method is called for each incoming WebSocket connection and must return a WebSocketEventHandler (or null to reject the connection).

public class EchoService extends WebSocketService {

    @Override
    protected WebSocketEventHandler createConnectionHandler(
            String requestPath, Headers upgradeHeaders) {
        return new DefaultWebSocketEventHandler() {

            @Override
            public void textMessageReceived(WebSocketSession session,
                                            String message) {
                try {
                    session.sendText("Echo: " + message);
                } catch (IOException e) {
                    // handle error
                }
            }
        };
    }
}

The service receives the request path and upgrade headers, which can be used for routing or authentication decisions:

@Override
protected WebSocketEventHandler createConnectionHandler(
        String requestPath, Headers upgradeHeaders) {
    
    // Route based on path
    if ("/chat".equals(requestPath)) {
        return new ChatHandler();
    } else if ("/notifications".equals(requestPath)) {
        return new NotificationHandler();
    }
    
    // Reject unknown paths
    return null;
}

Event Handler API

The WebSocketEventHandler interface provides five callbacks:

DefaultWebSocketEventHandler provides empty implementations of all methods for convenience.

WebSocket Session Methods

The WebSocketSession interface is provided to the handler in each callback. It does not need to be stored between calls:

Close Codes

Standard close codes are defined in WebSocketConnection.CloseCodes:

Subprotocol Negotiation

WebSocket supports application-level subprotocol negotiation via the Sec-WebSocket-Protocol header. Override selectSubprotocol on the service to participate:

@Override
protected String selectSubprotocol(Headers upgradeHeaders) {
    String requested = upgradeHeaders.getValue("Sec-WebSocket-Protocol");
    if (requested != null) {
        // Client may request multiple protocols, comma-separated
        if (requested.contains("graphql-ws")) {
            return "graphql-ws";
        }
        if (requested.contains("mqtt")) {
            return "mqtt";
        }
    }
    return null;  // no subprotocol
}

Extensions & permessage-deflate

RFC 6455 §9 defines an extension negotiation mechanism via the Sec-WebSocket-Extensions header. Extensions can augment the protocol — for example by compressing message payloads.

Gumdrop implements a general-purpose extension framework through the WebSocketExtension interface. Each extension declares which RSV bits it uses, participates in the opening handshake negotiation, and provides encode/decode transforms applied to message payloads.

permessage-deflate (RFC 7692)

The built-in PerMessageDeflateExtension implements RFC 7692, compressing WebSocket messages with DEFLATE. It is enabled by default on both WebSocketListener (server) and WebSocketClient (client). When a client offers permessage-deflate and the server accepts, all data messages are transparently compressed.

The RSV1 bit is set on the first frame of each compressed message. The trailing empty DEFLATE block marker (0x00 0x00 0xFF 0xFF) is stripped from compressed output and re-appended before decompression, per RFC 7692 §7.2.1.

Supported negotiation parameters:

To disable permessage-deflate on the server:

WebSocketListener listener = new WebSocketListener();
listener.setDeflateEnabled(false);

To disable it on the client:

WebSocketClient client = new WebSocketClient("example.com", 443);
client.setDeflateEnabled(false);

Custom Extensions

Implement the WebSocketExtension interface to create a custom extension. Key methods:

Maximum Message Size

RFC 6455 §7.4.1 defines close code 1009 (Message Too Big) for when a received message exceeds the endpoint’s capacity. Gumdrop enforces a configurable maximum message size on each WebSocketConnection:

// Set on the connection (e.g. from an event handler)
// 0 = unlimited (default)
connection.setMaxMessageSize(1024 * 1024);  // 1 MB limit

When a single-frame message or a fragmented message assembly exceeds this limit, the connection is proactively closed with code 1009. This prevents memory exhaustion from malicious or misbehaving peers.

Close Code Validation

RFC 6455 §7.4 defines rules for close codes that appear on the wire. Codes 1004, 1005, 1006, and 1015 are reserved and MUST NOT appear in close frames; codes outside the 1000–4999 range are also invalid. Gumdrop now validates received close codes and rejects invalid ones with a protocol error (close code 1002):

WebSocket Client

The WebSocketClient class provides a high-level facade for connecting to WebSocket servers. It uses the same WebSocketEventHandler interface as the server, so handler code can be reused in both roles.

WebSocketClient client = new WebSocketClient("echo.example.com", 443);
client.setSecure(true);

client.connect("/ws", new DefaultWebSocketEventHandler() {

    @Override
    public void opened(WebSocketSession session) {
        try {
            session.sendText("Hello from Gumdrop!");
        } catch (IOException e) {
            // handle error
        }
    }

    @Override
    public void textMessageReceived(WebSocketSession session,
                                    String message) {
        System.out.println("Received: " + message);
    }

    @Override
    public void closed(int code, String reason) {
        System.out.println("Connection closed: " + code);
    }

    @Override
    public void error(Throwable cause) {
        cause.printStackTrace();
    }
});

Client Configuration

SelectorLoop Affinity

Like the HTTP client, the WebSocket client can be assigned to a specific SelectorLoop for efficient integration with server-side code:

// Use the same event loop as the server connection
WebSocketClient client = new WebSocketClient(selectorLoop,
        "upstream.example.com", 443);
client.setSecure(true);
client.connect("/events", handler);

This is useful for server-side scenarios where a Gumdrop service needs to maintain a WebSocket connection to an upstream service (for example, subscribing to a Nostr relay from within a server handler).

Use Cases

Nostr Relay

Nostr is a decentralised social protocol where clients exchange JSON events with relay servers over WebSocket. A Nostr relay is a natural fit for Gumdrop’s WebSocket service:

public class NostrRelayService extends WebSocketService {

    private final NostrEventStore store = new NostrEventStore();
    private final SubscriptionManager subscriptions =
            new SubscriptionManager();

    @Override
    protected WebSocketEventHandler createConnectionHandler(
            String requestPath, Headers upgradeHeaders) {
        return new DefaultWebSocketEventHandler() {
            private WebSocketSession session;

            @Override
            public void opened(WebSocketSession session) {
                this.session = session;
            }

            @Override
            public void textMessageReceived(WebSocketSession session,
                                            String message) {
                // Parse Nostr message: ["EVENT", ...], ["REQ", ...], ["CLOSE", ...]
                NostrMessage msg = NostrMessage.parse(message);
                try {
                    if (msg.isEvent()) {
                        store.save(msg.getEvent());
                        session.sendText("[\"OK\",\"" + msg.getEventId()
                                + "\",true,\"\"]");
                        subscriptions.broadcast(msg.getEvent());
                    } else if (msg.isRequest()) {
                        subscriptions.subscribe(session,
                                msg.getSubscriptionId(),
                                msg.getFilters());
                    } else if (msg.isClose()) {
                        subscriptions.unsubscribe(session,
                                msg.getSubscriptionId());
                    }
                } catch (IOException e) {
                    // handle send error
                }
            }

            @Override
            public void closed(int code, String reason) {
                subscriptions.removeAll(session);
            }
        };
    }
}

Live Dashboard

Push server-side metrics to browser clients in real time:

public class DashboardService extends WebSocketService {

    private final Set<WebSocketSession> sessions =
            Collections.synchronizedSet(new HashSet<WebSocketSession>());

    @Override
    protected WebSocketEventHandler createConnectionHandler(
            String requestPath, Headers upgradeHeaders) {
        return new DefaultWebSocketEventHandler() {
            private WebSocketSession session;

            @Override
            public void opened(WebSocketSession session) {
                this.session = session;
                sessions.add(session);
            }

            @Override
            public void closed(int code, String reason) {
                sessions.remove(session);
            }
        };
    }

    /** Called periodically by a timer to push metrics. */
    public void broadcastMetrics(String json) {
        synchronized (sessions) {
            for (WebSocketSession session : sessions) {
                if (session.isOpen()) {
                    try {
                        session.sendText(json);
                    } catch (IOException e) {
                        // remove on next tick
                    }
                }
            }
        }
    }
}

IoT / Sensor Gateway

Binary WebSocket messages are well suited for compact sensor data formats. Devices connect over WebSocket and push binary telemetry:

@Override
public void binaryMessageReceived(WebSocketSession session,
                                  ByteBuffer data) {
    SensorReading reading = SensorReading.decode(data);
    telemetryPipeline.ingest(reading);
}

Chat

A basic chat server can be built by broadcasting text messages to all connected sessions, following the same pattern as the dashboard example above with an additional textMessageReceived handler that calls broadcastMessage.

Configuration

Basic WebSocket Service

<service id="echo" class="com.example.EchoService">
  <listener class="org.bluezoo.gumdrop.websocket.WebSocketListener">
    <property name="port">8080</property>
  </listener>
</service>

Secure WebSocket (wss://)

<service id="echo" class="com.example.EchoService">
  <listener class="org.bluezoo.gumdrop.websocket.WebSocketListener">
    <property name="port">443</property>
    <property name="secure">true</property>
    <property name="keystore-file">/etc/gumdrop/keystore.p12</property>
    <property name="keystore-pass">changeit</property>
  </listener>
</service>

Multiple Listeners

A service can expose both plaintext and TLS listeners:

<service id="relay" class="com.example.NostrRelayService">
  <listener class="org.bluezoo.gumdrop.websocket.WebSocketListener">
    <property name="port">8080</property>
  </listener>
  <listener class="org.bluezoo.gumdrop.websocket.WebSocketListener">
    <property name="port">8443</property>
    <property name="secure">true</property>
    <property name="keystore-file">/etc/gumdrop/keystore.p12</property>
    <property name="keystore-pass">changeit</property>
  </listener>
</service>

Nostr Relay Configuration

A complete gumdroprc for running a Nostr relay:

<gumdroprc>
  <gumdrop workers="4"/>

  <service id="nostr" class="com.example.NostrRelayService">
    <property name="event-store-path">/var/lib/nostr/events</property>
    <listener class="org.bluezoo.gumdrop.websocket.WebSocketListener">
      <property name="port">443</property>
      <property name="secure">true</property>
      <property name="keystore-file">/etc/gumdrop/keystore.p12</property>
      <property name="keystore-pass">changeit</property>
    </listener>
  </service>
</gumdroprc>

WebSocketListener Properties

WebSocketListener extends HTTPListener and inherits all of its properties:

Metrics

When telemetry is configured on the parent HTTP listener, WebSocketServerMetrics records the following OpenTelemetry metrics. Access via WebSocketListener.getWebSocketMetrics().

MetricTypeDescription
websocket.server.connectionsCounterTotal WebSocket connections
websocket.server.session.durationHistogramSession duration (ms)
websocket.server.messages.receivedCounterMessages received (by type)
websocket.server.messages.sentCounterMessages sent (by type)
websocket.server.frames.receivedCounterFrames received (by opcode)
websocket.server.frames.sentCounterFrames sent (by opcode)
websocket.server.errorsCounterWebSocket errors

Manual HTTP Upgrade

If you are building an HTTP service with the HTTP server handler API and want to support WebSocket alongside regular HTTP requests, you can perform the upgrade manually using HTTPResponseState.upgradeToWebSocket():

public class MyHandler extends DefaultHTTPRequestHandler {

    @Override
    public void headers(HTTPResponseState state, Headers headers) {
        if (WebSocketHandshake.isValidWebSocketUpgrade(headers)) {
            String protocol = headers.getValue("Sec-WebSocket-Protocol");
            state.upgradeToWebSocket(protocol, new EchoWebSocketHandler());
        } else {
            // Handle as regular HTTP request
            Headers response = new Headers();
            response.status(HTTPStatus.OK);
            response.add("content-type", "text/plain");
            state.headers(response);
            state.startResponseBody();
            state.responseBodyContent(
                ByteBuffer.wrap("Hello!".getBytes()));
            state.endResponseBody();
            state.complete();
        }
    }
}

This approach is useful when a single HTTP service needs to handle both regular HTTP traffic and WebSocket connections on the same port. For dedicated WebSocket services, the WebSocketService / WebSocketListener model described above is preferred because the HTTP upgrade is handled automatically.

Servlet Container Integration

The servlet container (ServletService) provides its own WebSocket upgrade path via the standard HttpServletRequest.upgrade() mechanism. Both the standalone WebSocketService and the servlet container converge on the same WebSocketEventHandler interface and share the same internal WebSocket frame engine (WebSocketConnection). This means the underlying protocol handling is identical regardless of how the upgrade was initiated.

For applications using the servlet container, WebSocket support is available through the standard Java WebSocket API (JSR 356) or by calling HttpServletRequest.upgrade() directly. For applications that do not need the servlet programming model, the standalone WebSocketService provides a simpler, more direct API.


← Back to Main Page | HTTP Server & Client | Servlet Container | SMTP Server & Client | DNS Server | Telemetry

Gumdrop WebSocket Service & Client