From 3b10bbcb7bf2203bc2360123184abb49e560239a Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:33:47 -0400 Subject: [PATCH 1/7] feat: add WebSocket API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- changes.md | 7 + org.restlet/src/main/java/module-info.java | 1 + .../restlet/engine/netty/NettyServerCall.java | 25 ++ .../engine/netty/NettyServerHelper.java | 62 ++++ .../engine/netty/NettyWebSocketConnector.java | 276 +++++++++++++++ .../engine/netty/NettyWebSocketSession.java | 324 ++++++++++++++++++ .../engine/netty/NettyWebSocketUpgrader.java | 149 ++++++++ .../restlet/websocket/WebSocketClient.java | 185 ++++++++++ .../restlet/websocket/WebSocketHandler.java | 26 ++ .../restlet/websocket/WebSocketMessage.java | 99 ++++++ .../restlet/websocket/WebSocketResource.java | 80 +++++ .../restlet/websocket/WebSocketSession.java | 128 +++++++ .../restlet/websocket/WebSocketUpgrade.java | 145 ++++++++ .../org/restlet/websocket/package-info.java | 14 + .../engine/connector/WebSocketTestCase.java | 316 +++++++++++++++++ .../websocket/WebSocketUpgradeTestCase.java | 116 +++++++ 16 files changed, 1953 insertions(+) create mode 100644 org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketConnector.java create mode 100644 org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketSession.java create mode 100644 org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketUpgrader.java create mode 100644 org.restlet/src/main/java/org/restlet/websocket/WebSocketClient.java create mode 100644 org.restlet/src/main/java/org/restlet/websocket/WebSocketHandler.java create mode 100644 org.restlet/src/main/java/org/restlet/websocket/WebSocketMessage.java create mode 100644 org.restlet/src/main/java/org/restlet/websocket/WebSocketResource.java create mode 100644 org.restlet/src/main/java/org/restlet/websocket/WebSocketSession.java create mode 100644 org.restlet/src/main/java/org/restlet/websocket/WebSocketUpgrade.java create mode 100644 org.restlet/src/main/java/org/restlet/websocket/package-info.java create mode 100644 org.restlet/src/test/java/org/restlet/engine/connector/WebSocketTestCase.java create mode 100644 org.restlet/src/test/java/org/restlet/websocket/WebSocketUpgradeTestCase.java diff --git a/changes.md b/changes.md index b17e9584ee..502cf304ee 100644 --- a/changes.md +++ b/changes.md @@ -66,6 +66,13 @@ Changes log streaming and reading Server-Sent Events over the HTTP connectors, including optional comment heartbeats and reconnects honoring `retry:` and `Last-Event-ID`. The `server.maxEventStreams` parameter limits concurrent event streams. + - Added experimental WebSocket support in the new `org.restlet.websocket` package, on + HTTP/1.1 upgrades. `WebSocketResource` (or `WebSocketUpgrade.accept`) accepts upgrades + that went through the normal routing, filters and authenticators, and runs a + `WebSocketHandler` on its own virtual thread with a blocking `WebSocketSession` + (`receive`, `sendText`, `sendBinary`, `close`). `WebSocketClient` opens client + sessions on "ws" and "wss" URIs. The server answers pings, pings idle sessions, and + supports the "server.maxWebSockets" and "websocket.maxMessageSize" parameters. - Added `Response.getProtocol()` and `setProtocol()`: the protocol, with its version, used by the server to reply (for example HTTP/1.1, HTTP/2.0 or HTTP/3.0), set by the HTTP client connector. When unknown, the protocol of the request is returned. diff --git a/org.restlet/src/main/java/module-info.java b/org.restlet/src/main/java/module-info.java index 520bad7227..500f3a7060 100644 --- a/org.restlet/src/main/java/module-info.java +++ b/org.restlet/src/main/java/module-info.java @@ -52,4 +52,5 @@ exports org.restlet.security; exports org.restlet.service; exports org.restlet.util; + exports org.restlet.websocket; } diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java index f4b0623a5e..d9023add39 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java @@ -64,6 +64,7 @@ import org.restlet.representation.Representation; import org.restlet.service.ConnectorService; import org.restlet.util.Series; +import org.restlet.websocket.WebSocketUpgrade; /** * Server exchange bridging a Netty HTTP request (HTTP/1.1, or one HTTP/2 or HTTP/3 stream converted @@ -356,6 +357,30 @@ private InputStream peek(InputStream stream) { * @param response The Restlet response. */ void commit(Response response) { + WebSocketUpgrade upgrade = WebSocketUpgrade.get(response); + if (upgrade != null && Status.INFO_SWITCHING_PROTOCOL.equals(response.getStatus())) { + if (kind != StreamKind.HTTP1) { + response.setStatus( + Status.SERVER_ERROR_NOT_IMPLEMENTED, + "WebSocket is only supported over HTTP/1.1"); + } else if (!helper.tryAcquireWebSocket()) { + response.setStatus(Status.SERVER_ERROR_SERVICE_UNAVAILABLE); + } else { + completed = true; + responseHeadWritten = true; + requestBody.close(); + try { + NettyWebSocketUpgrader.upgrade(helper, channel, request, confidential, upgrade); + } finally { + if (response.getOnSent() != null) { + response.getOnSent().handle(response.getRequest(), response); + } + } + return; + } + response.setEntity(null); + } + try { responseTrailers = response.getTrailers(); if (isEventStream(response.getEntity())) { diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHelper.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHelper.java index f9f32d5a66..f9a1edbab7 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHelper.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHelper.java @@ -117,6 +117,20 @@ * When 0, there is no limit. * * + * server.maxWebSockets + * int + * 0 + * Maximum number of open WebSocket sessions; extra upgrades receive a 503 response. When 0, + * there is no limit. + * + * + * websocket.maxMessageSize + * int + * 65536 + * Maximum size in bytes of a received WebSocket message; larger messages close the session + * with status 1009. + * + * * eventLoop.bossThreads * int * 1 @@ -211,6 +225,9 @@ public abstract class NettyServerHelper extends ServerHelper { /** Limits concurrent SSE streams, or null when unlimited. */ private volatile Semaphore eventStreamPermits; + /** Limits open WebSocket sessions, or null when unlimited. */ + private volatile Semaphore webSocketPermits; + /** The number of calls in progress. */ private final AtomicInteger activeCalls = new AtomicInteger(); @@ -522,6 +539,20 @@ void releaseEventStream() { } } + /** Tries to acquire a permit for a WebSocket session. */ + boolean tryAcquireWebSocket() { + Semaphore permits = webSocketPermits; + return !stopping && (permits == null || permits.tryAcquire()); + } + + /** Releases an acquired WebSocket session permit. */ + void releaseWebSocket() { + Semaphore permits = webSocketPermits; + if (permits != null) { + permits.release(); + } + } + /** * Returns the executor running Restlet calls. * @@ -546,6 +577,8 @@ public synchronized void start() throws Exception { callPermits = (maxCalls > 0) ? new Semaphore(maxCalls) : null; int maxEventStreams = getServerMaxEventStreams(); eventStreamPermits = (maxEventStreams > 0) ? new Semaphore(maxEventStreams) : null; + int maxWebSockets = getServerMaxWebSockets(); + webSocketPermits = (maxWebSockets > 0) ? new Semaphore(maxWebSockets) : null; callExecutor = CallExecutors.create( "restlet-server", @@ -860,6 +893,35 @@ public int getServerMaxEventStreams() { return result; } + /** + * Maximum number of open WebSocket sessions. Defaults to 0 (unlimited). + * + * @return Maximum number of open WebSocket sessions. + */ + public int getServerMaxWebSockets() { + int result = + Integer.parseInt(getHelpedParameters().getFirstValue("server.maxWebSockets", "0")); + if (result < 0) { + throw new IllegalArgumentException("'server.maxWebSockets' cannot be negative"); + } + return result; + } + + /** + * Maximum size in bytes of a received WebSocket message. Defaults to 65536. + * + * @return Maximum WebSocket message size. + */ + public int getWebSocketMaxMessageSize() { + int result = + Integer.parseInt( + getHelpedParameters().getFirstValue("websocket.maxMessageSize", "65536")); + if (result <= 0) { + throw new IllegalArgumentException("'websocket.maxMessageSize' must be positive"); + } + return result; + } + /** * Number of event loop threads accepting connections. Defaults to 1. * diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketConnector.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketConnector.java new file mode 100644 index 0000000000..14c7b4e824 --- /dev/null +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketConnector.java @@ -0,0 +1,276 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.netty; + +import io.netty.bootstrap.Bootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelOption; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.MultiThreadIoEventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioIoHandler; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpClientCodec; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakerFactory; +import io.netty.handler.codec.http.websocketx.WebSocketFrameAggregator; +import io.netty.handler.codec.http.websocketx.WebSocketVersion; +import io.netty.handler.ssl.ApplicationProtocolConfig; +import io.netty.handler.ssl.ClientAuth; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslHandler; +import io.netty.handler.timeout.IdleStateHandler; +import io.netty.util.concurrent.Promise; +import java.io.IOException; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; +import org.restlet.websocket.WebSocketSession; + +/** + * Netty implementation of the WebSocket client: one connection per session, HTTP/1.1 upgrade. + * + *

This class is internal to the Netty connectors; use {@link org.restlet.websocket + * .WebSocketClient}. + */ +public final class NettyWebSocketConnector implements AutoCloseable { + + private final EventLoopGroup group; + + private final SslContext sslContext; + + private final long connectTimeoutMs; + + private final long idleTimeoutMs; + + private final int maxMessageSize; + + private final boolean hostnameVerification; + + private final Logger logger; + + /** + * Constructor. + * + * @param sslContext The JDK SSL context used for "wss" URIs, or null to use the default one. + * @param connectTimeoutMs The connect and handshake timeout in milliseconds, 0 for none. + * @param idleTimeoutMs The idle time after which the connection is pinged, then closed, 0 for + * none. + * @param maxMessageSize The maximum size in bytes of a received message. + * @param hostnameVerification Whether the server host name is verified for "wss" URIs. + * @param logger The logger. + * @throws IOException If the SSL context cannot be created. + */ + public NettyWebSocketConnector( + SSLContext sslContext, + long connectTimeoutMs, + long idleTimeoutMs, + int maxMessageSize, + boolean hostnameVerification, + Logger logger) + throws IOException { + try { + SSLContext jdkContext = (sslContext != null) ? sslContext : SSLContext.getDefault(); + this.sslContext = + NettySsl.newContext( + jdkContext, true, ApplicationProtocolConfig.DISABLED, ClientAuth.NONE); + } catch (Exception e) { + throw new IOException("Unable to create the WebSocket SSL context", e); + } + this.connectTimeoutMs = connectTimeoutMs; + this.idleTimeoutMs = idleTimeoutMs; + this.maxMessageSize = maxMessageSize; + this.hostnameVerification = hostnameVerification; + this.logger = logger; + this.group = new MultiThreadIoEventLoopGroup(0, NioIoHandler.newFactory()); + } + + /** + * Opens a session, blocking until the handshake completes. + * + * @param uri The "ws" or "wss" URI. + * @param subprotocols The subprotocols to offer, in preference order, possibly empty. + * @param headers Additional handshake request headers. + * @return The open session. + * @throws IOException If the connection or the handshake fails. + */ + public WebSocketSession connect(URI uri, List subprotocols, Map headers) + throws IOException { + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(); + boolean secure; + if ("wss".equals(scheme)) { + secure = true; + } else if ("ws".equals(scheme)) { + secure = false; + } else { + throw new IllegalArgumentException("Unsupported WebSocket URI scheme: " + scheme); + } + String host = uri.getHost(); + if (host == null) { + throw new IllegalArgumentException("The WebSocket URI has no host: " + uri); + } + int port = (uri.getPort() > 0) ? uri.getPort() : (secure ? 443 : 80); + + HttpHeaders extraHeaders = new DefaultHttpHeaders(); + headers.forEach(extraHeaders::add); + WebSocketClientHandshaker handshaker = + WebSocketClientHandshakerFactory.newHandshaker( + uri, + WebSocketVersion.V13, + subprotocols.isEmpty() ? null : String.join(",", subprotocols), + false, + extraHeaders, + maxMessageSize); + + Promise ready = group.next().newPromise(); + Bootstrap bootstrap = + new Bootstrap() + .group(group) + .channel(NioSocketChannel.class) + .option(ChannelOption.TCP_NODELAY, true) + .handler( + new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + initPipeline(ch, secure, host, port, handshaker, ready); + } + }); + if (connectTimeoutMs > 0) { + bootstrap.option( + ChannelOption.CONNECT_TIMEOUT_MILLIS, + (int) Math.min(Integer.MAX_VALUE, connectTimeoutMs)); + } + ChannelFuture connect = bootstrap.connect(host, port); + Channel channel = + NettyFutures.await(connect, connectTimeoutMs, "Unable to connect to " + uri); + try { + NettyWebSocketSession session = + NettyFutures.await(ready, connectTimeoutMs, "WebSocket handshake with " + uri); + session.start(); + return session; + } catch (IOException e) { + channel.close(); + throw e; + } + } + + private void initPipeline( + SocketChannel ch, + boolean secure, + String host, + int port, + WebSocketClientHandshaker handshaker, + Promise ready) { + ChannelPipeline p = ch.pipeline(); + if (idleTimeoutMs > 0) { + p.addLast("idle", new IdleStateHandler(0, 0, idleTimeoutMs, TimeUnit.MILLISECONDS)); + } + p.addLast("writability", Writability.HANDLER); + if (secure) { + SSLEngine engine = sslContext.newEngine(ch.alloc(), host, port); + if (hostnameVerification) { + SSLParameters parameters = engine.getSSLParameters(); + parameters.setEndpointIdentificationAlgorithm("HTTPS"); + engine.setSSLParameters(parameters); + } + SslHandler sslHandler = new SslHandler(engine); + sslHandler.setHandshakeTimeoutMillis(connectTimeoutMs); + p.addLast("ssl", sslHandler); + } + p.addLast("http-codec", new HttpClientCodec()); + p.addLast("http-aggregator", new HttpObjectAggregator(8192)); + p.addLast("ws-handshake", new HandshakeHandler(handshaker, ready)); + } + + @Override + public void close() { + group.shutdownGracefully(0, 2, TimeUnit.SECONDS).awaitUninterruptibly(5, TimeUnit.SECONDS); + } + + /** Sends the handshake request and installs the session once the response is accepted. */ + private final class HandshakeHandler extends SimpleChannelInboundHandler { + + private final WebSocketClientHandshaker handshaker; + + private final Promise ready; + + HandshakeHandler( + WebSocketClientHandshaker handshaker, Promise ready) { + this.handshaker = handshaker; + this.ready = ready; + } + + @Override + public void channelActive(ChannelHandlerContext ctx) throws Exception { + handshaker + .handshake(ctx.channel()) + .addListener( + f -> { + if (!f.isSuccess()) { + ready.tryFailure(f.cause()); + ctx.close(); + } + }); + super.channelActive(ctx); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, FullHttpResponse response) { + Channel channel = ctx.channel(); + try { + handshaker.finishHandshake(channel, response); + } catch (RuntimeException e) { + ready.tryFailure( + new IOException("WebSocket handshake rejected: " + response.status(), e)); + ctx.close(); + return; + } + NettyWebSocketSession session = + new NettyWebSocketSession( + channel, + handshaker.actualSubprotocol(), + maxMessageSize, + connectTimeoutMs, + logger); + ChannelPipeline p = ctx.pipeline(); + p.addAfter(ctx.name(), "ws-aggregator", new WebSocketFrameAggregator(maxMessageSize)); + p.addAfter("ws-aggregator", "ws-session", session.newHandler()); + p.remove(this); + if (!ready.trySuccess(session)) { + ctx.close(); + } + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + ready.tryFailure(new IOException("Connection closed during the WebSocket handshake")); + super.channelInactive(ctx); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + ready.tryFailure(cause); + ctx.close(); + } + } +} diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketSession.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketSession.java new file mode 100644 index 0000000000..b0848eade9 --- /dev/null +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketSession.java @@ -0,0 +1,324 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.netty; + +import io.netty.buffer.ByteBufUtil; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.handler.codec.TooLongFrameException; +import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; +import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame; +import io.netty.handler.codec.http.websocketx.CorruptedWebSocketFrameException; +import io.netty.handler.codec.http.websocketx.PingWebSocketFrame; +import io.netty.handler.codec.http.websocketx.PongWebSocketFrame; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus; +import io.netty.handler.codec.http.websocketx.WebSocketFrame; +import io.netty.handler.timeout.IdleStateEvent; +import io.netty.util.ReferenceCountUtil; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.restlet.websocket.WebSocketMessage; +import org.restlet.websocket.WebSocketSession; + +/** + * WebSocket session over a Netty channel whose pipeline decodes WebSocket frames and aggregates + * fragmented messages. Received messages are queued for a blocking consumer; the channel is read on + * demand, so a slow consumer slows down the peer. Pings are answered with pongs, and an idle + * connection is pinged once before being closed. + * + *

This class is internal to the Netty connectors. + */ +final class NettyWebSocketSession implements WebSocketSession { + + /** Marks the end of the inbound messages. */ + private static final Object END = new Object(); + + /** Number of queued messages above which the channel is no longer read. */ + private static final int MAX_QUEUED_MESSAGES = 16; + + /** Maximum time to wait for the peer to complete the close handshake. */ + private static final long CLOSE_TIMEOUT_MS = 5000; + + private final Channel channel; + + private volatile String subprotocol; + + private final int maxMessageSize; + + private final long writeTimeoutMs; + + private final Logger logger; + + private final LinkedBlockingQueue inbound = new LinkedBlockingQueue<>(); + + private final AtomicBoolean readPaused = new AtomicBoolean(); + + private final AtomicBoolean closeSent = new AtomicBoolean(); + + private final ReentrantLock sendLock = new ReentrantLock(); + + private volatile boolean closeReceived; + + private volatile int closeCode = -1; + + private volatile String closeReason; + + private volatile boolean pingOutstanding; + + /** + * Constructor. + * + * @param channel The channel carrying the session. + * @param subprotocol The negotiated subprotocol, or null. + * @param maxMessageSize The maximum size in bytes of a received message. + * @param writeTimeoutMs The maximum time to wait for the channel to become writable, 0 for no + * limit. + * @param logger The logger. + */ + NettyWebSocketSession( + Channel channel, + String subprotocol, + int maxMessageSize, + long writeTimeoutMs, + Logger logger) { + this.channel = channel; + setSubprotocol(subprotocol); + this.maxMessageSize = maxMessageSize; + this.writeTimeoutMs = writeTimeoutMs; + this.logger = logger; + } + + /** + * Creates the inbound handler feeding this session, to add at the end of the pipeline. + * + * @return The handler. + */ + ChannelHandler newHandler() { + return new Handler(); + } + + /** + * Sets the negotiated subprotocol, known once the client handshake completes. + * + * @param subprotocol The subprotocol, or null. + */ + void setSubprotocol(String subprotocol) { + this.subprotocol = (subprotocol == null || subprotocol.isEmpty()) ? null : subprotocol; + } + + /** Starts reading messages on demand. */ + void start() { + channel.config().setAutoRead(false); + channel.read(); + } + + @Override + public String getSubprotocol() { + return subprotocol; + } + + @Override + public WebSocketMessage receive() throws IOException { + Object next; + try { + next = inbound.take(); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException("Interrupted while receiving a WebSocket message"); + } + if (next == END) { + inbound.offer(END); + return null; + } + if (inbound.size() < MAX_QUEUED_MESSAGES && readPaused.compareAndSet(true, false)) { + channel.read(); + } + return (WebSocketMessage) next; + } + + @Override + public void sendText(String text) throws IOException { + send(new TextWebSocketFrame(text)); + } + + @Override + public void sendBinary(byte[] data) throws IOException { + send(new BinaryWebSocketFrame(Unpooled.wrappedBuffer(data.clone()))); + } + + private void send(WebSocketFrame frame) throws IOException { + sendLock.lock(); + try { + if (!isOpen()) { + throw new IOException("The WebSocket session is closed"); + } + Writability.await(channel, writeTimeoutMs); + ChannelFuture future = channel.writeAndFlush(frame); + frame = null; + if (future.isDone() && !future.isSuccess()) { + throw new IOException("Unable to send the WebSocket message", future.cause()); + } + } finally { + sendLock.unlock(); + if (frame != null) { + frame.release(); + } + } + } + + @Override + public void close(int code, String reason) throws IOException { + if (closeSent.compareAndSet(false, true) && channel.isActive()) { + channel.writeAndFlush(new CloseWebSocketFrame(code, reason)); + } + try { + if (!channel.closeFuture().await(CLOSE_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + channel.close(); + } + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + channel.close(); + throw new InterruptedIOException("Interrupted while closing the WebSocket session"); + } + } + + @Override + public boolean isOpen() { + return channel.isActive() && !closeSent.get() && !closeReceived; + } + + @Override + public int getCloseCode() { + return closeCode; + } + + @Override + public String getCloseReason() { + return closeReason; + } + + private void enqueue(WebSocketMessage message) { + inbound.offer(message); + } + + private void onClose(ChannelHandlerContext ctx, int status, String reason) { + closeCode = (status < 0) ? NO_STATUS : status; + closeReason = (reason == null || reason.isEmpty()) ? null : reason; + closeReceived = true; + if (closeSent.compareAndSet(false, true)) { + int echo = + WebSocketCloseStatus.isValidStatusCode(closeCode) ? closeCode : NORMAL_CLOSURE; + ctx.writeAndFlush(new CloseWebSocketFrame(echo, null)) + .addListener(ChannelFutureListener.CLOSE); + } else { + ctx.close(); + } + inbound.offer(END); + } + + private void failSession(ChannelHandlerContext ctx, int code) { + if (closeSent.compareAndSet(false, true) && ctx.channel().isActive()) { + ctx.writeAndFlush(new CloseWebSocketFrame(code, null)) + .addListener(ChannelFutureListener.CLOSE); + } else { + ctx.close(); + } + } + + /** Converts frames into queued messages and handles the control frames. */ + private final class Handler extends ChannelInboundHandlerAdapter { + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + try { + pingOutstanding = false; + if (msg instanceof WebSocketFrame frame + && !(msg instanceof CloseWebSocketFrame) + && frame.content().readableBytes() > maxMessageSize) { + failSession(ctx, MESSAGE_TOO_BIG); + } else if (msg instanceof TextWebSocketFrame text) { + enqueue(WebSocketMessage.text(text.text())); + } else if (msg instanceof BinaryWebSocketFrame binary) { + enqueue(WebSocketMessage.binary(ByteBufUtil.getBytes(binary.content()))); + } else if (msg instanceof PingWebSocketFrame ping) { + ctx.writeAndFlush(new PongWebSocketFrame(ping.content().retain())); + } else if (msg instanceof CloseWebSocketFrame close) { + onClose(ctx, close.statusCode(), close.reasonText()); + } + } finally { + ReferenceCountUtil.release(msg); + } + } + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) { + if (!closeReceived && ctx.channel().isActive()) { + if (inbound.size() < MAX_QUEUED_MESSAGES) { + ctx.read(); + } else { + readPaused.set(true); + // The consumer may have drained the queue before the flag was set. + if (inbound.size() < MAX_QUEUED_MESSAGES + && readPaused.compareAndSet(true, false)) { + ctx.read(); + } + } + } + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + if (!closeReceived) { + closeCode = ABNORMAL_CLOSURE; + closeReceived = true; + } + inbound.offer(END); + super.channelInactive(ctx); + } + + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { + if (evt instanceof IdleStateEvent) { + if (pingOutstanding) { + ctx.close(); + } else { + pingOutstanding = true; + ctx.writeAndFlush(new PingWebSocketFrame()); + } + } else { + super.userEventTriggered(ctx, evt); + } + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + int code = INTERNAL_ERROR; + if (cause instanceof CorruptedWebSocketFrameException corrupted) { + code = corrupted.closeStatus().code(); + } else if (cause instanceof TooLongFrameException) { + code = MESSAGE_TOO_BIG; + } else { + logger.log(Level.FINE, "WebSocket session failure", cause); + } + failSession(ctx, code); + } + } +} diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketUpgrader.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketUpgrader.java new file mode 100644 index 0000000000..c9aa1bfe47 --- /dev/null +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketUpgrader.java @@ -0,0 +1,149 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.netty; + +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelPipeline; +import io.netty.handler.codec.http.DefaultFullHttpRequest; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpServerKeepAliveHandler; +import io.netty.handler.codec.http.HttpServerUpgradeHandler; +import io.netty.handler.codec.http.websocketx.WebSocketDecoderConfig; +import io.netty.handler.codec.http.websocketx.WebSocketFrameAggregator; +import io.netty.handler.codec.http.websocketx.WebSocketServerHandshaker; +import io.netty.handler.codec.http.websocketx.WebSocketServerHandshakerFactory; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import org.restlet.engine.Engine; +import org.restlet.websocket.WebSocketHandler; +import org.restlet.websocket.WebSocketSession; +import org.restlet.websocket.WebSocketUpgrade; + +/** + * Switches an HTTP/1.1 server connection to WebSocket after a Restlet accepted the upgrade, then + * runs the session handler on its own virtual thread. + * + *

This class is internal to the Netty connectors. + */ +final class NettyWebSocketUpgrader { + + /** Maximum time to wait for the handshake response to be written. */ + private static final long HANDSHAKE_TIMEOUT_MS = 10_000; + + private NettyWebSocketUpgrader() {} + + /** + * Performs the server handshake and starts the session. On failure, the connection is closed. + * The caller must have acquired a WebSocket permit, released here when the connection closes. + * + * @param helper The server helper. + * @param channel The HTTP/1.1 connection. + * @param request The upgrade request head. + * @param confidential True if the connection uses TLS. + * @param upgrade The accepted upgrade. + */ + static void upgrade( + NettyServerHelper helper, + Channel channel, + HttpRequest request, + boolean confidential, + WebSocketUpgrade upgrade) { + channel.closeFuture().addListener(f -> helper.releaseWebSocket()); + int maxMessageSize = helper.getWebSocketMaxMessageSize(); + + FullHttpRequest full = + new DefaultFullHttpRequest( + request.protocolVersion(), request.method(), request.uri()); + full.headers().set(request.headers()); + String host = request.headers().get(HttpHeaderNames.HOST, "localhost"); + String location = (confidential ? "wss://" : "ws://") + host + request.uri(); + + WebSocketServerHandshakerFactory factory = + new WebSocketServerHandshakerFactory( + location, + upgrade.getSubprotocol(), + WebSocketDecoderConfig.newBuilder() + .maxFramePayloadLength(maxMessageSize) + .allowExtensions(false) + .build()); + WebSocketServerHandshaker handshaker = factory.newHandshaker(full); + if (handshaker == null) { + WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(channel) + .addListener(f -> channel.close()); + full.release(); + return; + } + + ChannelPipeline pipeline = channel.pipeline(); + removeIfPresent(pipeline, HttpServerKeepAliveHandler.class); + removeIfPresent(pipeline, HttpServerUpgradeHandler.class); + removeIfPresent(pipeline, NettyServerHandler.class); + + NettyWebSocketSession session = + new NettyWebSocketSession( + channel, + handshaker.selectedSubprotocol(), + maxMessageSize, + helper.getConnectorIdleTimeout(), + helper.getLogger()); + // Installed before the handshake so that no frame sent right after it is missed. + pipeline.addLast("ws-aggregator", new WebSocketFrameAggregator(maxMessageSize)); + pipeline.addLast("ws-session", session.newHandler()); + try { + ChannelFuture future = handshaker.handshake(channel, full); + if (!future.await(HANDSHAKE_TIMEOUT_MS, TimeUnit.MILLISECONDS) || !future.isSuccess()) { + helper.getLogger().log(Level.FINE, "WebSocket handshake failed", future.cause()); + channel.close(); + return; + } + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + channel.close(); + return; + } finally { + full.release(); + } + + session.setSubprotocol(handshaker.selectedSubprotocol()); + session.start(); + + WebSocketHandler handler = upgrade.getHandler(); + Thread.ofVirtual().name("restlet-websocket").start(() -> run(helper, session, handler)); + } + + private static void run( + NettyServerHelper helper, NettyWebSocketSession session, WebSocketHandler handler) { + try { + handler.handle(session); + session.close(WebSocketSession.NORMAL_CLOSURE, null); + } catch (Exception | LinkageError | AssertionError e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + helper.getLogger().log(Level.WARNING, "Error while handling a WebSocket session", e); + try { + session.close(WebSocketSession.INTERNAL_ERROR, null); + } catch (Exception closeFailure) { + e.addSuppressed(closeFailure); + } + } finally { + Engine.clearThreadLocalVariables(); + } + } + + private static void removeIfPresent( + ChannelPipeline pipeline, Class type) { + if (pipeline.get(type) != null) { + pipeline.remove(type); + } + } +} diff --git a/org.restlet/src/main/java/org/restlet/websocket/WebSocketClient.java b/org.restlet/src/main/java/org/restlet/websocket/WebSocketClient.java new file mode 100644 index 0000000000..b677989a6e --- /dev/null +++ b/org.restlet/src/main/java/org/restlet/websocket/WebSocketClient.java @@ -0,0 +1,185 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.websocket; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.logging.Logger; +import javax.net.ssl.SSLContext; +import org.restlet.engine.netty.NettyWebSocketConnector; + +/** + * Opens WebSocket client sessions ("ws" and "wss" URIs) over HTTP/1.1 upgrades. Each session uses + * its own connection. Close the client to release its I/O threads. + */ +public final class WebSocketClient implements AutoCloseable { + + private final NettyWebSocketConnector connector; + + private WebSocketClient(Builder builder) throws IOException { + this.connector = + new NettyWebSocketConnector( + builder.sslContext, + builder.connectTimeout.toMillis(), + builder.idleTimeout.toMillis(), + builder.maxMessageSize, + builder.hostnameVerification, + Logger.getLogger(WebSocketClient.class.getName())); + } + + /** + * Creates a client with the default settings. + * + * @return The client. + * @throws IOException If the client cannot be created. + */ + public static WebSocketClient create() throws IOException { + return builder().build(); + } + + /** + * Returns a builder. + * + * @return The builder. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Opens a session, blocking until the handshake completes. + * + * @param uri The "ws" or "wss" URI. + * @return The open session. + * @throws IOException If the connection or the handshake fails. + */ + public WebSocketSession connect(String uri) throws IOException { + return connect(URI.create(uri), List.of(), Map.of()); + } + + /** + * Opens a session, blocking until the handshake completes. + * + * @param uri The "ws" or "wss" URI. + * @param subprotocols The subprotocols to offer, in preference order. + * @param headers Additional handshake request headers, for example "Authorization". + * @return The open session. + * @throws IOException If the connection or the handshake fails. + */ + public WebSocketSession connect(URI uri, List subprotocols, Map headers) + throws IOException { + return connector.connect( + Objects.requireNonNull(uri, "uri"), + List.copyOf(subprotocols), + new LinkedHashMap<>(headers)); + } + + @Override + public void close() { + connector.close(); + } + + /** Builds a {@link WebSocketClient}. */ + public static final class Builder { + + private SSLContext sslContext; + + private Duration connectTimeout = Duration.ofSeconds(15); + + private Duration idleTimeout = Duration.ofSeconds(30); + + private int maxMessageSize = 65536; + + private boolean hostnameVerification = true; + + private Builder() {} + + /** + * Sets the SSL context used for "wss" URIs. Defaults to the JVM default context. + * + * @param sslContext The SSL context. + * @return This builder. + */ + public Builder sslContext(SSLContext sslContext) { + this.sslContext = sslContext; + return this; + } + + /** + * Sets the connect and handshake timeout. Defaults to 15 seconds; zero for none. + * + * @param connectTimeout The timeout. + * @return This builder. + */ + public Builder connectTimeout(Duration connectTimeout) { + this.connectTimeout = requireNonNegative(connectTimeout, "connectTimeout"); + return this; + } + + /** + * Sets the idle time after which the server is pinged, the connection being closed if it + * stays idle as long again. Defaults to 30 seconds; zero to disable. + * + * @param idleTimeout The idle timeout. + * @return This builder. + */ + public Builder idleTimeout(Duration idleTimeout) { + this.idleTimeout = requireNonNegative(idleTimeout, "idleTimeout"); + return this; + } + + /** + * Sets the maximum size in bytes of a received message. Defaults to 65536. + * + * @param maxMessageSize The maximum message size. + * @return This builder. + */ + public Builder maxMessageSize(int maxMessageSize) { + if (maxMessageSize <= 0) { + throw new IllegalArgumentException("maxMessageSize must be positive"); + } + this.maxMessageSize = maxMessageSize; + return this; + } + + /** + * Sets whether the server host name is verified for "wss" URIs. Defaults to true. + * + * @param hostnameVerification True to verify the host name. + * @return This builder. + */ + public Builder hostnameVerification(boolean hostnameVerification) { + this.hostnameVerification = hostnameVerification; + return this; + } + + /** + * Builds the client. + * + * @return The client. + * @throws IOException If the client cannot be created. + */ + public WebSocketClient build() throws IOException { + return new WebSocketClient(this); + } + + private static Duration requireNonNegative(Duration value, String name) { + Objects.requireNonNull(value, name); + if (value.isNegative()) { + throw new IllegalArgumentException(name + " cannot be negative"); + } + return value; + } + } +} diff --git a/org.restlet/src/main/java/org/restlet/websocket/WebSocketHandler.java b/org.restlet/src/main/java/org/restlet/websocket/WebSocketHandler.java new file mode 100644 index 0000000000..239c687b3a --- /dev/null +++ b/org.restlet/src/main/java/org/restlet/websocket/WebSocketHandler.java @@ -0,0 +1,26 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.websocket; + +/** + * Handles an accepted WebSocket session with straight-line blocking code. It runs on its own + * virtual thread; when it returns, the session is closed normally, and when it throws, the session + * is closed with {@link WebSocketSession#INTERNAL_ERROR}. + */ +@FunctionalInterface +public interface WebSocketHandler { + + /** + * Handles the session until it is done. + * + * @param session The open session. + * @throws Exception If the handling fails. + */ + void handle(WebSocketSession session) throws Exception; +} diff --git a/org.restlet/src/main/java/org/restlet/websocket/WebSocketMessage.java b/org.restlet/src/main/java/org/restlet/websocket/WebSocketMessage.java new file mode 100644 index 0000000000..cf51adb499 --- /dev/null +++ b/org.restlet/src/main/java/org/restlet/websocket/WebSocketMessage.java @@ -0,0 +1,99 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.websocket; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; + +/** An immutable, complete WebSocket data message: either text or binary. */ +public final class WebSocketMessage { + + private final String text; + + private final byte[] data; + + private WebSocketMessage(String text, byte[] data) { + this.text = text; + this.data = data; + } + + /** + * Creates a text message. + * + * @param text The text. + * @return The message. + */ + public static WebSocketMessage text(String text) { + return new WebSocketMessage(Objects.requireNonNull(text, "text"), null); + } + + /** + * Creates a binary message. + * + * @param data The bytes, copied. + * @return The message. + */ + public static WebSocketMessage binary(byte[] data) { + return new WebSocketMessage(null, Objects.requireNonNull(data, "data").clone()); + } + + /** + * Indicates whether this is a text message. + * + * @return True for a text message. + */ + public boolean isText() { + return text != null; + } + + /** + * Indicates whether this is a binary message. + * + * @return True for a binary message. + */ + public boolean isBinary() { + return data != null; + } + + /** + * Returns the text of a text message, or the UTF-8 decoding of a binary message. + * + * @return The text. + */ + public String getText() { + return isText() ? text : new String(data, StandardCharsets.UTF_8); + } + + /** + * Returns the bytes of a binary message, or the UTF-8 encoding of a text message. + * + * @return A copy of the bytes. + */ + public byte[] getData() { + return isBinary() ? data.clone() : text.getBytes(StandardCharsets.UTF_8); + } + + @Override + public boolean equals(Object obj) { + return obj instanceof WebSocketMessage that + && Objects.equals(text, that.text) + && Arrays.equals(data, that.data); + } + + @Override + public int hashCode() { + return 31 * Objects.hashCode(text) + Arrays.hashCode(data); + } + + @Override + public String toString() { + return isText() ? "text[" + text.length() + " chars]" : "binary[" + data.length + " bytes]"; + } +} diff --git a/org.restlet/src/main/java/org/restlet/websocket/WebSocketResource.java b/org.restlet/src/main/java/org/restlet/websocket/WebSocketResource.java new file mode 100644 index 0000000000..e3f9641127 --- /dev/null +++ b/org.restlet/src/main/java/org/restlet/websocket/WebSocketResource.java @@ -0,0 +1,80 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.websocket; + +import java.util.List; +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.data.MediaType; +import org.restlet.data.Status; + +/** + * Restlet accepting WebSocket upgrades. Attach it to a router like any Restlet: filters and + * authenticators in front of it apply to the upgrade request. Requests that do not ask for an + * upgrade receive a 400 (Bad Request) response. + */ +public abstract class WebSocketResource extends Restlet { + + /** Constructor. */ + protected WebSocketResource() { + super(); + } + + /** + * Constructor. + * + * @param context The context. + */ + protected WebSocketResource(Context context) { + super(context); + } + + @Override + public void handle(Request request, Response response) { + super.handle(request, response); + if (!WebSocketUpgrade.isRequested(request)) { + response.setStatus(Status.CLIENT_ERROR_BAD_REQUEST); + response.setEntity("A WebSocket upgrade is required", MediaType.TEXT_PLAIN); + return; + } + + WebSocketHandler handler = accept(request, response); + if (handler == null) { + if (response.getStatus() == null || response.getStatus().isSuccess()) { + response.setStatus(Status.CLIENT_ERROR_FORBIDDEN); + } + return; + } + WebSocketUpgrade.accept( + response, + handler, + selectSubprotocol(WebSocketUpgrade.getRequestedSubprotocols(request))); + } + + /** + * Accepts or rejects an upgrade request. + * + * @param request The upgrade request. + * @param response The response, whose status may be set when rejecting. + * @return The session handler, or null to reject the upgrade (403 by default). + */ + protected abstract WebSocketHandler accept(Request request, Response response); + + /** + * Selects a subprotocol among those offered by the client. By default, none is selected. + * + * @param requested The offered subprotocols, in preference order. + * @return The selected subprotocol, or null. + */ + protected String selectSubprotocol(List requested) { + return null; + } +} diff --git a/org.restlet/src/main/java/org/restlet/websocket/WebSocketSession.java b/org.restlet/src/main/java/org/restlet/websocket/WebSocketSession.java new file mode 100644 index 0000000000..c6130e8ef2 --- /dev/null +++ b/org.restlet/src/main/java/org/restlet/websocket/WebSocketSession.java @@ -0,0 +1,128 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.websocket; + +import java.io.IOException; + +/** + * An open WebSocket connection, used with blocking calls from a (virtual) thread. Messages are + * meant to be received by a single thread; they may be sent from several threads. + */ +public interface WebSocketSession extends AutoCloseable { + + /** Normal closure status code. */ + int NORMAL_CLOSURE = 1000; + + /** Status code sent when an endpoint is going away. */ + int GOING_AWAY = 1001; + + /** Status code sent on a protocol error. */ + int PROTOCOL_ERROR = 1002; + + /** Status code reported when the peer closed without a status code. */ + int NO_STATUS = 1005; + + /** Status code reported when the connection closed without a close frame. */ + int ABNORMAL_CLOSURE = 1006; + + /** Status code sent when a message is too big to process. */ + int MESSAGE_TOO_BIG = 1009; + + /** Status code sent on an unexpected condition. */ + int INTERNAL_ERROR = 1011; + + /** + * Returns the negotiated subprotocol. + * + * @return The subprotocol, or null if none was negotiated. + */ + String getSubprotocol(); + + /** + * Blocks until the next complete message arrives. Ping and pong frames are handled by the + * connector. + * + * @return The next message, or null once the session is closed. + * @throws IOException If the wait is interrupted. + */ + WebSocketMessage receive() throws IOException; + + /** + * Sends a text message, blocking while the peer is slow. + * + * @param text The text. + * @throws IOException If the session is closed or the write fails. + */ + void sendText(String text) throws IOException; + + /** + * Sends a binary message, blocking while the peer is slow. + * + * @param data The bytes. + * @throws IOException If the session is closed or the write fails. + */ + void sendBinary(byte[] data) throws IOException; + + /** + * Sends a message, blocking while the peer is slow. + * + * @param message The message. + * @throws IOException If the session is closed or the write fails. + */ + default void send(WebSocketMessage message) throws IOException { + if (message.isText()) { + sendText(message.getText()); + } else { + sendBinary(message.getData()); + } + } + + /** + * Starts the close handshake and waits briefly for the peer to complete it. Does nothing if the + * session is already closing. + * + * @param code The close status code (1000, or 3000-4999 for applications). + * @param reason The close reason, may be null. + * @throws IOException If the wait is interrupted. + */ + void close(int code, String reason) throws IOException; + + /** + * Closes the session normally. + * + * @throws IOException If the wait is interrupted. + */ + @Override + default void close() throws IOException { + close(NORMAL_CLOSURE, null); + } + + /** + * Indicates whether the session is still open: no close frame was sent or received and the + * connection is active. + * + * @return True if the session is open. + */ + boolean isOpen(); + + /** + * Returns the status code of the close frame received from the peer, {@link #NO_STATUS} if it + * had none, or {@link #ABNORMAL_CLOSURE} if the connection closed without one. + * + * @return The close status code, or -1 while no close frame was received. + */ + int getCloseCode(); + + /** + * Returns the reason of the close frame received from the peer. + * + * @return The close reason, or null. + */ + String getCloseReason(); +} diff --git a/org.restlet/src/main/java/org/restlet/websocket/WebSocketUpgrade.java b/org.restlet/src/main/java/org/restlet/websocket/WebSocketUpgrade.java new file mode 100644 index 0000000000..db65331c51 --- /dev/null +++ b/org.restlet/src/main/java/org/restlet/websocket/WebSocketUpgrade.java @@ -0,0 +1,145 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.websocket; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.data.Header; +import org.restlet.data.Method; +import org.restlet.data.Status; + +/** + * Accepts a WebSocket upgrade on a Restlet response. The upgrade request goes through the normal + * routing, filters and authenticators; the Restlet handling it calls {@link #accept(Response, + * WebSocketHandler, String)}, and the HTTP server connector then performs the handshake and runs + * the handler on its own virtual thread. + * + *

Only HTTP/1.1 upgrades are supported (not WebSocket over HTTP/2). + */ +public final class WebSocketUpgrade { + + /** Name of the response attribute holding the accepted upgrade. */ + public static final String ATTRIBUTE = "org.restlet.websocket.upgrade"; + + private final WebSocketHandler handler; + + private final String subprotocol; + + private WebSocketUpgrade(WebSocketHandler handler, String subprotocol) { + this.handler = handler; + this.subprotocol = subprotocol; + } + + /** + * Indicates whether a request asks for a WebSocket upgrade: a GET with "Upgrade: websocket" and + * "Connection: upgrade". + * + * @param request The request. + * @return True if a WebSocket upgrade is requested. + */ + public static boolean isRequested(Request request) { + return Method.GET.equals(request.getMethod()) + && hasToken(request, "Upgrade", "websocket") + && hasToken(request, "Connection", "upgrade"); + } + + /** + * Returns the subprotocols offered by the client in "Sec-WebSocket-Protocol", in preference + * order. + * + * @param request The request. + * @return The offered subprotocols, possibly empty. + */ + public static List getRequestedSubprotocols(Request request) { + List result = new ArrayList<>(); + for (Header header : request.getHeaders()) { + if ("Sec-WebSocket-Protocol".equalsIgnoreCase(header.getName()) + && header.getValue() != null) { + for (String token : header.getValue().split(",")) { + String value = token.trim(); + if (!value.isEmpty()) { + result.add(value); + } + } + } + } + return result; + } + + /** + * Accepts the upgrade: sets the "101 Switching Protocols" status and records the handler. The + * server connector completes the handshake after the response leaves the Restlet chain, unless + * the status was changed in between. + * + * @param response The response to the upgrade request. + * @param handler The session handler. + * @param subprotocol The selected subprotocol, one of those offered by the client, or null. + */ + public static void accept(Response response, WebSocketHandler handler, String subprotocol) { + Objects.requireNonNull(handler, "handler"); + Request request = response.getRequest(); + if (request == null || !isRequested(request)) { + throw new IllegalStateException("The request does not ask for a WebSocket upgrade"); + } + if (subprotocol != null && !getRequestedSubprotocols(request).contains(subprotocol)) { + throw new IllegalArgumentException( + "The subprotocol \"" + subprotocol + "\" was not offered by the client"); + } + response.getAttributes().put(ATTRIBUTE, new WebSocketUpgrade(handler, subprotocol)); + response.setStatus(Status.INFO_SWITCHING_PROTOCOL); + response.setEntity(null); + } + + /** + * Returns the upgrade accepted on a response. + * + * @param response The response. + * @return The accepted upgrade, or null. + */ + public static WebSocketUpgrade get(Response response) { + return (response.getAttributes().get(ATTRIBUTE) instanceof WebSocketUpgrade upgrade) + ? upgrade + : null; + } + + /** + * Returns the session handler. + * + * @return The session handler. + */ + public WebSocketHandler getHandler() { + return handler; + } + + /** + * Returns the selected subprotocol. + * + * @return The selected subprotocol, or null. + */ + public String getSubprotocol() { + return subprotocol; + } + + private static boolean hasToken(Request request, String name, String token) { + for (Header header : request.getHeaders()) { + if (name.equalsIgnoreCase(header.getName()) && header.getValue() != null) { + for (String value : header.getValue().split(",")) { + if (token.equals(value.trim().toLowerCase(Locale.ROOT))) { + return true; + } + } + } + } + return false; + } +} diff --git a/org.restlet/src/main/java/org/restlet/websocket/package-info.java b/org.restlet/src/main/java/org/restlet/websocket/package-info.java new file mode 100644 index 0000000000..1fe1eecdca --- /dev/null +++ b/org.restlet/src/main/java/org/restlet/websocket/package-info.java @@ -0,0 +1,14 @@ +/** + * Copyright 2005-2026 Qlik + * + *

The content of this file is subject to the terms of the Apache 2.0 open source license + * available at https://www.opensource.org/licenses/apache-2.0 + * + *

Restlet is a registered trademark of QlikTech International AB. + */ +/** + * WebSocket support (experimental): {@link org.restlet.websocket.WebSocketResource} accepts + * upgrades on the HTTP server connector, {@link org.restlet.websocket.WebSocketClient} opens client + * sessions, and both use the blocking {@link org.restlet.websocket.WebSocketSession}. + */ +package org.restlet.websocket; diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/WebSocketTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/WebSocketTestCase.java new file mode 100644 index 0000000000..9fb264141f --- /dev/null +++ b/org.restlet/src/test/java/org/restlet/engine/connector/WebSocketTestCase.java @@ -0,0 +1,316 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.connector; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.restlet.Application; +import org.restlet.Client; +import org.restlet.Component; +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.Server; +import org.restlet.data.Method; +import org.restlet.data.Protocol; +import org.restlet.data.Status; +import org.restlet.engine.Engine; +import org.restlet.routing.Router; +import org.restlet.security.Authenticator; +import org.restlet.websocket.WebSocketClient; +import org.restlet.websocket.WebSocketHandler; +import org.restlet.websocket.WebSocketMessage; +import org.restlet.websocket.WebSocketResource; +import org.restlet.websocket.WebSocketSession; + +class WebSocketTestCase { + + private Server server; + + private WebSocketClient client; + + @BeforeEach + void setUp() throws IOException { + Engine.clearThreadLocalVariables(); + Engine.register(false); + Engine.getInstance().getRegisteredServers().add(new HttpServerHelper(null)); + Engine.getInstance().getRegisteredClients().add(new HttpClientHelper(null)); + client = WebSocketClient.builder().connectTimeout(Duration.ofSeconds(5)).build(); + } + + @AfterEach + void tearDown() throws Exception { + client.close(); + if (server != null) { + server.stop(); + } + Engine.clearThreadLocalVariables(); + // Restore the default engine, with its discovered helpers, for the tests that follow. + Engine.register(); + } + + private static WebSocketResource resource(WebSocketHandler handler) { + return new WebSocketResource() { + @Override + protected WebSocketHandler accept(Request request, Response response) { + return handler; + } + + @Override + protected String selectSubprotocol(List requested) { + return requested.contains("chat") ? "chat" : null; + } + }; + } + + private static final WebSocketHandler ECHO = + session -> { + WebSocketMessage message; + while ((message = session.receive()) != null) { + session.send(message); + } + }; + + private String start(Restlet root, Context context) throws Exception { + server = new Server(context, Protocol.HTTP, 0, root); + server.start(); + return "ws://localhost:" + server.getActualPort(); + } + + @Test + void echoesTextAndBinaryMessagesWithSubprotocol() throws Exception { + String base = start(resource(ECHO), new Context()); + + try (WebSocketSession session = + client.connect(URI.create(base + "/echo"), List.of("other", "chat"), Map.of())) { + assertEquals("chat", session.getSubprotocol()); + session.sendText("héllo"); + WebSocketMessage text = session.receive(); + assertTrue(text.isText()); + assertEquals("héllo", text.getText()); + + byte[] bytes = {0, 1, 2, (byte) 255}; + session.sendBinary(bytes); + WebSocketMessage binary = session.receive(); + assertTrue(binary.isBinary()); + assertArrayEquals(bytes, binary.getData()); + } + } + + @Test + void serverHandlerReturningClosesTheSessionNormally() throws Exception { + String base = + start( + resource( + session -> { + session.sendText("bye"); + }), + new Context()); + + WebSocketSession session = client.connect(base + "/"); + assertEquals("bye", session.receive().getText()); + assertNull(session.receive()); + assertEquals(WebSocketSession.NORMAL_CLOSURE, session.getCloseCode()); + assertFalse(session.isOpen()); + } + + @Test + void clientCloseIsReportedToTheServer() throws Exception { + AtomicReference serverCode = new AtomicReference<>(); + AtomicReference serverReason = new AtomicReference<>(); + CountDownLatch closed = new CountDownLatch(1); + String base = + start( + resource( + session -> { + assertNull(session.receive()); + serverCode.set(session.getCloseCode()); + serverReason.set(session.getCloseReason()); + closed.countDown(); + }), + new Context()); + + WebSocketSession session = client.connect(base + "/"); + session.close(4000, "done"); + + assertTrue(closed.await(5, TimeUnit.SECONDS)); + assertEquals(4000, serverCode.get()); + assertEquals("done", serverReason.get()); + assertEquals(4000, session.getCloseCode()); + assertThrows(IOException.class, () -> session.sendText("late")); + } + + @Test + void largerMessagesThanTheLimitCloseTheSession() throws Exception { + Context context = new Context(); + context.getParameters().add("websocket.maxMessageSize", "16"); + String base = start(resource(ECHO), context); + + WebSocketSession session = client.connect(base + "/"); + session.sendText("x".repeat(64)); + assertNull(session.receive()); + assertEquals(WebSocketSession.MESSAGE_TOO_BIG, session.getCloseCode()); + } + + @Test + void routingAndAuthenticationApplyToTheUpgrade() throws Exception { + Application application = + new Application() { + @Override + public Restlet createInboundRoot() { + Router router = new Router(getContext()); + router.attach("/echo", resource(ECHO)); + Authenticator guard = + new Authenticator(getContext()) { + @Override + protected boolean authenticate( + Request request, Response response) { + boolean ok = + "secret" + .equals( + request.getHeaders() + .getFirstValue( + "X-Token", true)); + if (!ok) { + response.setStatus(Status.CLIENT_ERROR_UNAUTHORIZED); + } + return ok; + } + }; + guard.setNext(router); + return guard; + } + }; + Component component = new Component(); + Server componentServer = component.getServers().add(Protocol.HTTP, 0); + component.getDefaultHost().attach("/app", application); + component.start(); + String base = "ws://localhost:" + componentServer.getActualPort() + "/app"; + + try { + IOException rejected = + assertThrows(IOException.class, () -> client.connect(base + "/echo")); + assertTrue(rejected.getMessage().contains("401"), rejected.getMessage()); + + try (WebSocketSession session = + client.connect( + URI.create(base + "/echo"), List.of(), Map.of("X-Token", "secret"))) { + session.sendText("ok"); + assertEquals("ok", session.receive().getText()); + } + + IOException missing = + assertThrows( + IOException.class, + () -> + client.connect( + URI.create(base + "/missing"), + List.of(), + Map.of("X-Token", "secret"))); + assertTrue(missing.getMessage().contains("404"), missing.getMessage()); + } finally { + component.stop(); + } + } + + @Test + void plainRequestsToAWebSocketResourceAreRejected() throws Exception { + String base = start(resource(ECHO), new Context()); + Client http = new Client(Protocol.HTTP); + http.start(); + try { + Response response = + http.handle(new Request(Method.GET, base.replace("ws:", "http:") + "/")); + assertEquals(Status.CLIENT_ERROR_BAD_REQUEST, response.getStatus()); + } finally { + http.stop(); + } + } + + @Test + void enforcesTheOpenSessionLimit() throws Exception { + Context context = new Context(); + context.getParameters().add("server.maxWebSockets", "1"); + String base = start(resource(ECHO), context); + + WebSocketSession first = client.connect(base + "/"); + IOException rejected = assertThrows(IOException.class, () -> client.connect(base + "/")); + assertTrue(rejected.getMessage().contains("503"), rejected.getMessage()); + + first.close(); + // The permit is released when the first connection is closed. + WebSocketSession second = null; + for (int i = 0; i < 50 && second == null; i++) { + try { + second = client.connect(base + "/"); + } catch (IOException e) { + Thread.sleep(50); + } + } + assertTrue(second != null && second.isOpen()); + second.close(); + } + + @Test + void interoperatesWithTheJdkWebSocketClient() throws Exception { + String base = start(resource(ECHO), new Context()); + CompletableFuture reply = new CompletableFuture<>(); + HttpClient jdk = HttpClient.newHttpClient(); + WebSocket ws = + jdk.newWebSocketBuilder() + .buildAsync( + URI.create(base + "/"), + new WebSocket.Listener() { + private final StringBuilder text = new StringBuilder(); + + @Override + public CompletionStage onText( + WebSocket webSocket, CharSequence data, boolean last) { + text.append(data); + if (last) { + reply.complete(text.toString()); + } + webSocket.request(1); + return null; + } + }) + .get(5, TimeUnit.SECONDS); + try { + ws.sendText("from jdk", true).get(5, TimeUnit.SECONDS); + assertEquals("from jdk", reply.get(5, TimeUnit.SECONDS)); + } catch (CompletionException e) { + throw e; + } finally { + ws.sendClose(WebSocket.NORMAL_CLOSURE, "").get(5, TimeUnit.SECONDS); + jdk.close(); + } + } +} diff --git a/org.restlet/src/test/java/org/restlet/websocket/WebSocketUpgradeTestCase.java b/org.restlet/src/test/java/org/restlet/websocket/WebSocketUpgradeTestCase.java new file mode 100644 index 0000000000..6ed54e2cc5 --- /dev/null +++ b/org.restlet/src/test/java/org/restlet/websocket/WebSocketUpgradeTestCase.java @@ -0,0 +1,116 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.websocket; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.data.Method; +import org.restlet.data.Status; + +class WebSocketUpgradeTestCase { + + private static Request upgradeRequest(String... subprotocols) { + Request request = new Request(Method.GET, "http://localhost/ws"); + request.getHeaders().add("Upgrade", "WebSocket"); + request.getHeaders().add("Connection", "keep-alive, Upgrade"); + if (subprotocols.length > 0) { + request.getHeaders().add("Sec-WebSocket-Protocol", String.join(", ", subprotocols)); + } + return request; + } + + @Test + void detectsUpgradeRequests() { + assertTrue(WebSocketUpgrade.isRequested(upgradeRequest())); + assertFalse(WebSocketUpgrade.isRequested(new Request(Method.GET, "http://localhost/"))); + + Request post = upgradeRequest(); + post.setMethod(Method.POST); + assertFalse(WebSocketUpgrade.isRequested(post)); + } + + @Test + void parsesOfferedSubprotocols() { + assertEquals( + List.of("a", "b"), + WebSocketUpgrade.getRequestedSubprotocols(upgradeRequest("a", " b"))); + assertEquals(List.of(), WebSocketUpgrade.getRequestedSubprotocols(upgradeRequest())); + } + + @Test + void acceptRecordsTheUpgradeOnTheResponse() { + Response response = new Response(upgradeRequest("chat")); + WebSocketHandler handler = session -> {}; + + WebSocketUpgrade.accept(response, handler, "chat"); + + assertEquals(Status.INFO_SWITCHING_PROTOCOL, response.getStatus()); + WebSocketUpgrade upgrade = WebSocketUpgrade.get(response); + assertSame(handler, upgrade.getHandler()); + assertEquals("chat", upgrade.getSubprotocol()); + } + + @Test + void acceptRejectsInvalidUpgrades() { + assertThrows( + IllegalStateException.class, + () -> + WebSocketUpgrade.accept( + new Response(new Request(Method.GET, "http://localhost/")), + session -> {}, + null)); + assertThrows( + IllegalArgumentException.class, + () -> + WebSocketUpgrade.accept( + new Response(upgradeRequest("chat")), session -> {}, "other")); + assertNull(WebSocketUpgrade.get(new Response(upgradeRequest()))); + } + + @Test + void resourceRejectsWhenNoHandler() { + WebSocketResource refusing = + new WebSocketResource() { + @Override + protected WebSocketHandler accept(Request request, Response response) { + return null; + } + }; + Response response = new Response(upgradeRequest()); + refusing.handle(response.getRequest(), response); + assertEquals(Status.CLIENT_ERROR_FORBIDDEN, response.getStatus()); + assertNull(WebSocketUpgrade.get(response)); + } + + @Test + void messagesConvertBetweenTextAndBinary() { + WebSocketMessage text = WebSocketMessage.text("é"); + assertTrue(text.isText()); + assertArrayEquals("é".getBytes(StandardCharsets.UTF_8), text.getData()); + + byte[] bytes = "é".getBytes(StandardCharsets.UTF_8); + WebSocketMessage binary = WebSocketMessage.binary(bytes); + bytes[0] = 0; + assertTrue(binary.isBinary()); + assertEquals("é", binary.getText()); + assertEquals(WebSocketMessage.binary(binary.getData()), binary); + assertEquals("binary[2 bytes]", binary.toString()); + } +} From fceb401ed331197e4069fe650deeb32a2d633996 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:29:26 -0400 Subject: [PATCH 2/7] refactor: move WebSocket API to org.restlet.ext.websocket Keep only a generic NettyConnectionUpgrade hook in the core connector. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- changes.md | 17 +- .../src/main/java/module-info.java | 22 ++ .../websocket}/NettyWebSocketConnector.java | 66 ++++-- .../ext/websocket}/NettyWebSocketSession.java | 65 +++++- .../websocket}/NettyWebSocketUpgrader.java | 115 ++++++---- .../ext}/websocket/WebSocketClient.java | 3 +- .../ext}/websocket/WebSocketHandler.java | 2 +- .../ext}/websocket/WebSocketMessage.java | 2 +- .../ext}/websocket/WebSocketResource.java | 2 +- .../ext}/websocket/WebSocketSession.java | 2 +- .../ext}/websocket/WebSocketUpgrade.java | 15 +- .../restlet/ext/websocket/package-info.java | 20 ++ .../websocket/WebSocketSessionTestCase.java | 211 ++++++++++++++++++ .../ext/websocket}/WebSocketTestCase.java | 11 +- .../websocket/WebSocketUpgradeTestCase.java | 2 +- .../org/restlet/ext/websocket/dummy.p12 | Bin 0 -> 2726 bytes org.restlet/src/main/java/module-info.java | 1 - .../engine/netty/NettyConnectionUpgrade.java | 38 ++++ .../restlet/engine/netty/NettyServerCall.java | 64 ++++-- .../engine/netty/NettyServerHelper.java | 62 ----- .../engine/netty/NettyUpgradeContext.java | 106 +++++++++ .../org/restlet/websocket/package-info.java | 14 -- .../NettyConnectionUpgradeTestCase.java | 147 ++++++++++++ pom.xml | 1 + 24 files changed, 801 insertions(+), 187 deletions(-) create mode 100644 org.restlet.ext.websocket/src/main/java/module-info.java rename {org.restlet/src/main/java/org/restlet/engine/netty => org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket}/NettyWebSocketConnector.java (81%) rename {org.restlet/src/main/java/org/restlet/engine/netty => org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket}/NettyWebSocketSession.java (83%) rename {org.restlet/src/main/java/org/restlet/engine/netty => org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket}/NettyWebSocketUpgrader.java (53%) rename {org.restlet/src/main/java/org/restlet => org.restlet.ext.websocket/src/main/java/org/restlet/ext}/websocket/WebSocketClient.java (98%) rename {org.restlet/src/main/java/org/restlet => org.restlet.ext.websocket/src/main/java/org/restlet/ext}/websocket/WebSocketHandler.java (95%) rename {org.restlet/src/main/java/org/restlet => org.restlet.ext.websocket/src/main/java/org/restlet/ext}/websocket/WebSocketMessage.java (98%) rename {org.restlet/src/main/java/org/restlet => org.restlet.ext.websocket/src/main/java/org/restlet/ext}/websocket/WebSocketResource.java (98%) rename {org.restlet/src/main/java/org/restlet => org.restlet.ext.websocket/src/main/java/org/restlet/ext}/websocket/WebSocketSession.java (99%) rename {org.restlet/src/main/java/org/restlet => org.restlet.ext.websocket/src/main/java/org/restlet/ext}/websocket/WebSocketUpgrade.java (88%) create mode 100644 org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/package-info.java create mode 100644 org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketSessionTestCase.java rename {org.restlet/src/test/java/org/restlet/engine/connector => org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket}/WebSocketTestCase.java (97%) rename {org.restlet/src/test/java/org/restlet => org.restlet.ext.websocket/src/test/java/org/restlet/ext}/websocket/WebSocketUpgradeTestCase.java (99%) create mode 100644 org.restlet.ext.websocket/src/test/resources/org/restlet/ext/websocket/dummy.p12 create mode 100644 org.restlet/src/main/java/org/restlet/engine/netty/NettyConnectionUpgrade.java create mode 100644 org.restlet/src/main/java/org/restlet/engine/netty/NettyUpgradeContext.java delete mode 100644 org.restlet/src/main/java/org/restlet/websocket/package-info.java create mode 100644 org.restlet/src/test/java/org/restlet/engine/connector/NettyConnectionUpgradeTestCase.java diff --git a/changes.md b/changes.md index 502cf304ee..9b97fb34d0 100644 --- a/changes.md +++ b/changes.md @@ -66,13 +66,16 @@ Changes log streaming and reading Server-Sent Events over the HTTP connectors, including optional comment heartbeats and reconnects honoring `retry:` and `Last-Event-ID`. The `server.maxEventStreams` parameter limits concurrent event streams. - - Added experimental WebSocket support in the new `org.restlet.websocket` package, on - HTTP/1.1 upgrades. `WebSocketResource` (or `WebSocketUpgrade.accept`) accepts upgrades - that went through the normal routing, filters and authenticators, and runs a - `WebSocketHandler` on its own virtual thread with a blocking `WebSocketSession` - (`receive`, `sendText`, `sendBinary`, `close`). `WebSocketClient` opens client - sessions on "ws" and "wss" URIs. The server answers pings, pings idle sessions, and - supports the "server.maxWebSockets" and "websocket.maxMessageSize" parameters. + - Added the experimental `org.restlet.ext.websocket` extension, on HTTP/1.1 upgrades. + `WebSocketResource` (or `WebSocketUpgrade.accept`) accepts upgrades that went through + the normal routing, filters and authenticators, and runs a `WebSocketHandler` on its own + virtual thread with a blocking `WebSocketSession` (`receive`, `sendText`, `sendBinary`, + `close`). `WebSocketClient` opens client sessions on "ws" and "wss" URIs. The server + answers pings, pings idle sessions, and supports the "websocket.maxSessions" and + "websocket.maxMessageSize" parameters. + - Added the `NettyConnectionUpgrade` engine hook: a Restlet returning a "101 Switching + Protocols" response with this response attribute takes over the HTTP/1.1 connection + (`NettyUpgradeContext`), which the WebSocket extension relies on. - Added `Response.getProtocol()` and `setProtocol()`: the protocol, with its version, used by the server to reply (for example HTTP/1.1, HTTP/2.0 or HTTP/3.0), set by the HTTP client connector. When unknown, the protocol of the request is returned. diff --git a/org.restlet.ext.websocket/src/main/java/module-info.java b/org.restlet.ext.websocket/src/main/java/module-info.java new file mode 100644 index 0000000000..0037928816 --- /dev/null +++ b/org.restlet.ext.websocket/src/main/java/module-info.java @@ -0,0 +1,22 @@ +/** + * Copyright 2005-2026 Qlik + * + *

The content of this file is subject to the terms of the Apache 2.0 open source license + * available at https://www.opensource.org/licenses/apache-2.0 + * + *

Restlet is a registered trademark of QlikTech International AB. + */ +/** Restlet Framework WebSocket extension (experimental). */ +module org.restlet.ext.websocket { + requires transitive org.restlet; + + // Netty is an implementation detail of the extension: required, not re-exported. + requires io.netty.buffer; + requires io.netty.codec; + requires io.netty.codec.http; + requires io.netty.common; + requires io.netty.handler; + requires io.netty.transport; + + exports org.restlet.ext.websocket; +} diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketConnector.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketConnector.java similarity index 81% rename from org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketConnector.java rename to org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketConnector.java index 14c7b4e824..a6ded39fcc 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketConnector.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketConnector.java @@ -6,7 +6,7 @@ *

* Restlet is a registered trademark of QlikTech International AB. */ -package org.restlet.engine.netty; +package org.restlet.ext.websocket; import io.netty.bootstrap.Bootstrap; import io.netty.channel.Channel; @@ -32,11 +32,16 @@ import io.netty.handler.codec.http.websocketx.WebSocketVersion; import io.netty.handler.ssl.ApplicationProtocolConfig; import io.netty.handler.ssl.ClientAuth; +import io.netty.handler.ssl.IdentityCipherSuiteFilter; +import io.netty.handler.ssl.JdkSslContext; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslHandler; import io.netty.handler.timeout.IdleStateHandler; +import io.netty.util.concurrent.Future; import io.netty.util.concurrent.Promise; import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.ConnectException; import java.net.URI; import java.util.List; import java.util.Map; @@ -45,15 +50,13 @@ import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLParameters; -import org.restlet.websocket.WebSocketSession; /** * Netty implementation of the WebSocket client: one connection per session, HTTP/1.1 upgrade. * - *

This class is internal to the Netty connectors; use {@link org.restlet.websocket - * .WebSocketClient}. + *

This class is internal to the extension; use {@link WebSocketClient}. */ -public final class NettyWebSocketConnector implements AutoCloseable { +final class NettyWebSocketConnector implements AutoCloseable { private final EventLoopGroup group; @@ -81,7 +84,7 @@ public final class NettyWebSocketConnector implements AutoCloseable { * @param logger The logger. * @throws IOException If the SSL context cannot be created. */ - public NettyWebSocketConnector( + NettyWebSocketConnector( SSLContext sslContext, long connectTimeoutMs, long idleTimeoutMs, @@ -92,8 +95,15 @@ public NettyWebSocketConnector( try { SSLContext jdkContext = (sslContext != null) ? sslContext : SSLContext.getDefault(); this.sslContext = - NettySsl.newContext( - jdkContext, true, ApplicationProtocolConfig.DISABLED, ClientAuth.NONE); + new JdkSslContext( + jdkContext, + true, + null, + IdentityCipherSuiteFilter.INSTANCE, + ApplicationProtocolConfig.DISABLED, + ClientAuth.NONE, + null, + false); } catch (Exception e) { throw new IOException("Unable to create the WebSocket SSL context", e); } @@ -114,7 +124,7 @@ public NettyWebSocketConnector( * @return The open session. * @throws IOException If the connection or the handshake fails. */ - public WebSocketSession connect(URI uri, List subprotocols, Map headers) + WebSocketSession connect(URI uri, List subprotocols, Map headers) throws IOException { String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(); boolean secure; @@ -161,11 +171,11 @@ protected void initChannel(SocketChannel ch) { (int) Math.min(Integer.MAX_VALUE, connectTimeoutMs)); } ChannelFuture connect = bootstrap.connect(host, port); - Channel channel = - NettyFutures.await(connect, connectTimeoutMs, "Unable to connect to " + uri); + await(connect, "Unable to connect to " + uri); + Channel channel = connect.channel(); try { - NettyWebSocketSession session = - NettyFutures.await(ready, connectTimeoutMs, "WebSocket handshake with " + uri); + await(ready, "WebSocket handshake with " + uri); + NettyWebSocketSession session = ready.getNow(); session.start(); return session; } catch (IOException e) { @@ -174,6 +184,35 @@ protected void initChannel(SocketChannel ch) { } } + private void await(Future future, String message) throws IOException { + try { + boolean done = + (connectTimeoutMs > 0) + ? future.await(connectTimeoutMs, TimeUnit.MILLISECONDS) + : future.await().isDone(); + if (!done) { + future.cancel(false); + throw new ConnectException(message + ": timed out"); + } + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + future.cancel(false); + throw new InterruptedIOException(message + ": interrupted"); + } + if (!future.isSuccess()) { + Throwable cause = future.cause(); + if (future instanceof ChannelFuture channelFuture) { + channelFuture.channel().close(); + } + if (cause instanceof IOException ioe && !(future instanceof ChannelFuture)) { + throw new IOException(message + ": " + ioe.getMessage(), ioe); + } + ConnectException ce = new ConnectException(message + ": " + cause); + ce.initCause(cause); + throw ce; + } + } + private void initPipeline( SocketChannel ch, boolean secure, @@ -185,7 +224,6 @@ private void initPipeline( if (idleTimeoutMs > 0) { p.addLast("idle", new IdleStateHandler(0, 0, idleTimeoutMs, TimeUnit.MILLISECONDS)); } - p.addLast("writability", Writability.HANDLER); if (secure) { SSLEngine engine = sslContext.newEngine(ch.alloc(), host, port); if (hostnameVerification) { diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketSession.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketSession.java similarity index 83% rename from org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketSession.java rename to org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketSession.java index b0848eade9..cd853e9c37 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketSession.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketSession.java @@ -6,7 +6,7 @@ *

* Restlet is a registered trademark of QlikTech International AB. */ -package org.restlet.engine.netty; +package org.restlet.ext.websocket; import io.netty.buffer.ByteBufUtil; import io.netty.buffer.Unpooled; @@ -32,11 +32,10 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; import java.util.logging.Level; import java.util.logging.Logger; -import org.restlet.websocket.WebSocketMessage; -import org.restlet.websocket.WebSocketSession; /** * WebSocket session over a Netty channel whose pipeline decodes WebSocket frames and aggregates @@ -44,7 +43,7 @@ * demand, so a slow consumer slows down the peer. Pings are answered with pongs, and an idle * connection is pinged once before being closed. * - *

This class is internal to the Netty connectors. + *

This class is internal to the extension. */ final class NettyWebSocketSession implements WebSocketSession { @@ -75,6 +74,10 @@ final class NettyWebSocketSession implements WebSocketSession { private final ReentrantLock sendLock = new ReentrantLock(); + private final ReentrantLock writabilityLock = new ReentrantLock(); + + private final Condition writable = writabilityLock.newCondition(); + private volatile boolean closeReceived; private volatile int closeCode = -1; @@ -170,7 +173,7 @@ private void send(WebSocketFrame frame) throws IOException { if (!isOpen()) { throw new IOException("The WebSocket session is closed"); } - Writability.await(channel, writeTimeoutMs); + awaitWritable(); ChannelFuture future = channel.writeAndFlush(frame); frame = null; if (future.isDone() && !future.isSuccess()) { @@ -184,6 +187,49 @@ private void send(WebSocketFrame frame) throws IOException { } } + /** Blocks until the channel is writable, so that a slow peer slows down the sender. */ + private void awaitWritable() throws IOException { + if (channel.isWritable()) { + return; + } + channel.flush(); + long remaining = + (writeTimeoutMs > 0) + ? TimeUnit.MILLISECONDS.toNanos(writeTimeoutMs) + : Long.MAX_VALUE; + writabilityLock.lock(); + try { + while (!channel.isWritable()) { + if (!channel.isActive()) { + throw new IOException("Connection closed by peer"); + } + if (remaining <= 0) { + throw new InterruptedIOException("WebSocket write timed out"); + } + // Wake up periodically in case a signal raced with the check above. + long slice = Math.min(remaining, TimeUnit.MILLISECONDS.toNanos(100)); + long left = writable.awaitNanos(slice); + if (writeTimeoutMs > 0) { + remaining -= (slice - Math.max(0, left)); + } + } + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException("Interrupted while sending a WebSocket message"); + } finally { + writabilityLock.unlock(); + } + } + + private void signalWritability() { + writabilityLock.lock(); + try { + writable.signalAll(); + } finally { + writabilityLock.unlock(); + } + } + @Override public void close(int code, String reason) throws IOException { if (closeSent.compareAndSet(false, true) && channel.isActive()) { @@ -284,8 +330,17 @@ public void channelReadComplete(ChannelHandlerContext ctx) { } } + @Override + public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception { + if (ctx.channel().isWritable()) { + signalWritability(); + } + super.channelWritabilityChanged(ctx); + } + @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { + signalWritability(); if (!closeReceived) { closeCode = ABNORMAL_CLOSURE; closeReceived = true; diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketUpgrader.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketUpgrader.java similarity index 53% rename from org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketUpgrader.java rename to org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketUpgrader.java index c9aa1bfe47..ac17a8d1e6 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyWebSocketUpgrader.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketUpgrader.java @@ -6,66 +6,78 @@ *

* Restlet is a registered trademark of QlikTech International AB. */ -package org.restlet.engine.netty; +package org.restlet.ext.websocket; import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelPipeline; import io.netty.handler.codec.http.DefaultFullHttpRequest; +import io.netty.handler.codec.http.DefaultFullHttpResponse; import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaderValues; import io.netty.handler.codec.http.HttpRequest; -import io.netty.handler.codec.http.HttpServerKeepAliveHandler; -import io.netty.handler.codec.http.HttpServerUpgradeHandler; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpVersion; import io.netty.handler.codec.http.websocketx.WebSocketDecoderConfig; import io.netty.handler.codec.http.websocketx.WebSocketFrameAggregator; import io.netty.handler.codec.http.websocketx.WebSocketServerHandshaker; import io.netty.handler.codec.http.websocketx.WebSocketServerHandshakerFactory; +import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.logging.Level; +import org.restlet.data.Parameter; import org.restlet.engine.Engine; -import org.restlet.websocket.WebSocketHandler; -import org.restlet.websocket.WebSocketSession; -import org.restlet.websocket.WebSocketUpgrade; +import org.restlet.engine.netty.NettyConnectionUpgrade; +import org.restlet.engine.netty.NettyUpgradeContext; +import org.restlet.util.Series; /** * Switches an HTTP/1.1 server connection to WebSocket after a Restlet accepted the upgrade, then * runs the session handler on its own virtual thread. - * - *

This class is internal to the Netty connectors. */ -final class NettyWebSocketUpgrader { +final class NettyWebSocketUpgrader implements NettyConnectionUpgrade { + + /** Name of the server connector attribute holding the session permits. */ + private static final String PERMITS_ATTRIBUTE = "org.restlet.ext.websocket.permits"; /** Maximum time to wait for the handshake response to be written. */ private static final long HANDSHAKE_TIMEOUT_MS = 10_000; - private NettyWebSocketUpgrader() {} + private final WebSocketUpgrade upgrade; /** - * Performs the server handshake and starts the session. On failure, the connection is closed. - * The caller must have acquired a WebSocket permit, released here when the connection closes. + * Constructor. * - * @param helper The server helper. - * @param channel The HTTP/1.1 connection. - * @param request The upgrade request head. - * @param confidential True if the connection uses TLS. * @param upgrade The accepted upgrade. */ - static void upgrade( - NettyServerHelper helper, - Channel channel, - HttpRequest request, - boolean confidential, - WebSocketUpgrade upgrade) { - channel.closeFuture().addListener(f -> helper.releaseWebSocket()); - int maxMessageSize = helper.getWebSocketMaxMessageSize(); + NettyWebSocketUpgrader(WebSocketUpgrade upgrade) { + this.upgrade = upgrade; + } + + @Override + public void upgrade(NettyUpgradeContext context) throws Exception { + Channel channel = context.getChannel(); + Series parameters = context.getParameters(); + Semaphore permits = getPermits(context, parameters); + if (permits != null && !permits.tryAcquire()) { + sendError(channel, HttpResponseStatus.SERVICE_UNAVAILABLE); + return; + } + if (permits != null) { + channel.closeFuture().addListener(f -> permits.release()); + } + int maxMessageSize = getMaxMessageSize(parameters); + HttpRequest request = context.getRequest(); FullHttpRequest full = new DefaultFullHttpRequest( request.protocolVersion(), request.method(), request.uri()); full.headers().set(request.headers()); String host = request.headers().get(HttpHeaderNames.HOST, "localhost"); - String location = (confidential ? "wss://" : "ws://") + host + request.uri(); + String location = (context.isConfidential() ? "wss://" : "ws://") + host + request.uri(); WebSocketServerHandshakerFactory factory = new WebSocketServerHandshakerFactory( @@ -78,37 +90,29 @@ static void upgrade( WebSocketServerHandshaker handshaker = factory.newHandshaker(full); if (handshaker == null) { WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(channel) - .addListener(f -> channel.close()); + .addListener(ChannelFutureListener.CLOSE); full.release(); return; } - ChannelPipeline pipeline = channel.pipeline(); - removeIfPresent(pipeline, HttpServerKeepAliveHandler.class); - removeIfPresent(pipeline, HttpServerUpgradeHandler.class); - removeIfPresent(pipeline, NettyServerHandler.class); - NettyWebSocketSession session = new NettyWebSocketSession( channel, handshaker.selectedSubprotocol(), maxMessageSize, - helper.getConnectorIdleTimeout(), - helper.getLogger()); + context.getIdleTimeout(), + context.getLogger()); // Installed before the handshake so that no frame sent right after it is missed. + ChannelPipeline pipeline = channel.pipeline(); pipeline.addLast("ws-aggregator", new WebSocketFrameAggregator(maxMessageSize)); pipeline.addLast("ws-session", session.newHandler()); try { ChannelFuture future = handshaker.handshake(channel, full); if (!future.await(HANDSHAKE_TIMEOUT_MS, TimeUnit.MILLISECONDS) || !future.isSuccess()) { - helper.getLogger().log(Level.FINE, "WebSocket handshake failed", future.cause()); + context.getLogger().log(Level.FINE, "WebSocket handshake failed", future.cause()); channel.close(); return; } - } catch (InterruptedException _) { - Thread.currentThread().interrupt(); - channel.close(); - return; } finally { full.release(); } @@ -117,11 +121,11 @@ static void upgrade( session.start(); WebSocketHandler handler = upgrade.getHandler(); - Thread.ofVirtual().name("restlet-websocket").start(() -> run(helper, session, handler)); + Thread.ofVirtual().name("restlet-websocket").start(() -> run(context, session, handler)); } private static void run( - NettyServerHelper helper, NettyWebSocketSession session, WebSocketHandler handler) { + NettyUpgradeContext context, NettyWebSocketSession session, WebSocketHandler handler) { try { handler.handle(session); session.close(WebSocketSession.NORMAL_CLOSURE, null); @@ -129,7 +133,7 @@ private static void run( if (e instanceof InterruptedException) { Thread.currentThread().interrupt(); } - helper.getLogger().log(Level.WARNING, "Error while handling a WebSocket session", e); + context.getLogger().log(Level.WARNING, "Error while handling a WebSocket session", e); try { session.close(WebSocketSession.INTERNAL_ERROR, null); } catch (Exception closeFailure) { @@ -140,10 +144,31 @@ private static void run( } } - private static void removeIfPresent( - ChannelPipeline pipeline, Class type) { - if (pipeline.get(type) != null) { - pipeline.remove(type); + private static Semaphore getPermits(NettyUpgradeContext context, Series parameters) { + int max = Integer.parseInt(parameters.getFirstValue("websocket.maxSessions", "0")); + if (max < 0) { + throw new IllegalArgumentException("'websocket.maxSessions' cannot be negative"); + } + if (max == 0) { + return null; + } + return (Semaphore) + context.getAttributes().computeIfAbsent(PERMITS_ATTRIBUTE, k -> new Semaphore(max)); + } + + private static int getMaxMessageSize(Series parameters) { + int result = + Integer.parseInt(parameters.getFirstValue("websocket.maxMessageSize", "65536")); + if (result <= 0) { + throw new IllegalArgumentException("'websocket.maxMessageSize' must be positive"); } + return result; + } + + private static void sendError(Channel channel, HttpResponseStatus status) { + FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status); + response.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0); + response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); + channel.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); } } diff --git a/org.restlet/src/main/java/org/restlet/websocket/WebSocketClient.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java similarity index 98% rename from org.restlet/src/main/java/org/restlet/websocket/WebSocketClient.java rename to org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java index b677989a6e..ec6c4b70f7 100644 --- a/org.restlet/src/main/java/org/restlet/websocket/WebSocketClient.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java @@ -6,7 +6,7 @@ *

* Restlet is a registered trademark of QlikTech International AB. */ -package org.restlet.websocket; +package org.restlet.ext.websocket; import java.io.IOException; import java.net.URI; @@ -17,7 +17,6 @@ import java.util.Objects; import java.util.logging.Logger; import javax.net.ssl.SSLContext; -import org.restlet.engine.netty.NettyWebSocketConnector; /** * Opens WebSocket client sessions ("ws" and "wss" URIs) over HTTP/1.1 upgrades. Each session uses diff --git a/org.restlet/src/main/java/org/restlet/websocket/WebSocketHandler.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketHandler.java similarity index 95% rename from org.restlet/src/main/java/org/restlet/websocket/WebSocketHandler.java rename to org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketHandler.java index 239c687b3a..086760af18 100644 --- a/org.restlet/src/main/java/org/restlet/websocket/WebSocketHandler.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketHandler.java @@ -6,7 +6,7 @@ *

* Restlet is a registered trademark of QlikTech International AB. */ -package org.restlet.websocket; +package org.restlet.ext.websocket; /** * Handles an accepted WebSocket session with straight-line blocking code. It runs on its own diff --git a/org.restlet/src/main/java/org/restlet/websocket/WebSocketMessage.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketMessage.java similarity index 98% rename from org.restlet/src/main/java/org/restlet/websocket/WebSocketMessage.java rename to org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketMessage.java index cf51adb499..7b1b3d0530 100644 --- a/org.restlet/src/main/java/org/restlet/websocket/WebSocketMessage.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketMessage.java @@ -6,7 +6,7 @@ *

* Restlet is a registered trademark of QlikTech International AB. */ -package org.restlet.websocket; +package org.restlet.ext.websocket; import java.nio.charset.StandardCharsets; import java.util.Arrays; diff --git a/org.restlet/src/main/java/org/restlet/websocket/WebSocketResource.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketResource.java similarity index 98% rename from org.restlet/src/main/java/org/restlet/websocket/WebSocketResource.java rename to org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketResource.java index e3f9641127..ba30ba9058 100644 --- a/org.restlet/src/main/java/org/restlet/websocket/WebSocketResource.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketResource.java @@ -6,7 +6,7 @@ *

* Restlet is a registered trademark of QlikTech International AB. */ -package org.restlet.websocket; +package org.restlet.ext.websocket; import java.util.List; import org.restlet.Context; diff --git a/org.restlet/src/main/java/org/restlet/websocket/WebSocketSession.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketSession.java similarity index 99% rename from org.restlet/src/main/java/org/restlet/websocket/WebSocketSession.java rename to org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketSession.java index c6130e8ef2..1be7e4196c 100644 --- a/org.restlet/src/main/java/org/restlet/websocket/WebSocketSession.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketSession.java @@ -6,7 +6,7 @@ *

* Restlet is a registered trademark of QlikTech International AB. */ -package org.restlet.websocket; +package org.restlet.ext.websocket; import java.io.IOException; diff --git a/org.restlet/src/main/java/org/restlet/websocket/WebSocketUpgrade.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketUpgrade.java similarity index 88% rename from org.restlet/src/main/java/org/restlet/websocket/WebSocketUpgrade.java rename to org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketUpgrade.java index db65331c51..2ce6afeaf6 100644 --- a/org.restlet/src/main/java/org/restlet/websocket/WebSocketUpgrade.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketUpgrade.java @@ -6,7 +6,7 @@ *

* Restlet is a registered trademark of QlikTech International AB. */ -package org.restlet.websocket; +package org.restlet.ext.websocket; import java.util.ArrayList; import java.util.List; @@ -17,19 +17,21 @@ import org.restlet.data.Header; import org.restlet.data.Method; import org.restlet.data.Status; +import org.restlet.engine.netty.NettyConnectionUpgrade; /** * Accepts a WebSocket upgrade on a Restlet response. The upgrade request goes through the normal * routing, filters and authenticators; the Restlet handling it calls {@link #accept(Response, - * WebSocketHandler, String)}, and the HTTP server connector then performs the handshake and runs - * the handler on its own virtual thread. + * WebSocketHandler, String)}, and the Netty HTTP server connector of the core then hands the + * connection over to this extension, which performs the handshake and runs the handler on its own + * virtual thread. * *

Only HTTP/1.1 upgrades are supported (not WebSocket over HTTP/2). */ public final class WebSocketUpgrade { /** Name of the response attribute holding the accepted upgrade. */ - public static final String ATTRIBUTE = "org.restlet.websocket.upgrade"; + public static final String ATTRIBUTE = "org.restlet.ext.websocket.upgrade"; private final WebSocketHandler handler; @@ -95,7 +97,10 @@ public static void accept(Response response, WebSocketHandler handler, String su throw new IllegalArgumentException( "The subprotocol \"" + subprotocol + "\" was not offered by the client"); } - response.getAttributes().put(ATTRIBUTE, new WebSocketUpgrade(handler, subprotocol)); + WebSocketUpgrade upgrade = new WebSocketUpgrade(handler, subprotocol); + response.getAttributes().put(ATTRIBUTE, upgrade); + response.getAttributes() + .put(NettyConnectionUpgrade.ATTRIBUTE, new NettyWebSocketUpgrader(upgrade)); response.setStatus(Status.INFO_SWITCHING_PROTOCOL); response.setEntity(null); } diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/package-info.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/package-info.java new file mode 100644 index 0000000000..befb67dc05 --- /dev/null +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/package-info.java @@ -0,0 +1,20 @@ +/** + * Copyright 2005-2026 Qlik + * + *

The content of this file is subject to the terms of the Apache 2.0 open source license + * available at https://www.opensource.org/licenses/apache-2.0 + * + *

Restlet is a registered trademark of QlikTech International AB. + */ +/** + * WebSocket support (experimental): {@link org.restlet.ext.websocket.WebSocketResource} accepts + * upgrades on the Netty HTTP server connector of the core, {@link + * org.restlet.ext.websocket.WebSocketClient} opens client sessions, and both use the blocking + * {@link org.restlet.ext.websocket.WebSocketSession}. + * + *

Server connector parameters read by this extension: "websocket.maxSessions" (maximum number of + * open sessions, extra upgrades receive a 503 response; 0, the default, for no limit) and + * "websocket.maxMessageSize" (maximum size in bytes of a received message, 65536 by default; larger + * messages close the session with status 1009). + */ +package org.restlet.ext.websocket; diff --git a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketSessionTestCase.java b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketSessionTestCase.java new file mode 100644 index 0000000000..248656b509 --- /dev/null +++ b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketSessionTestCase.java @@ -0,0 +1,211 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.net.ConnectException; +import java.net.ServerSocket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.time.Duration; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Server; +import org.restlet.data.Protocol; +import org.restlet.engine.Engine; +import org.restlet.engine.connector.HttpServerHelper; +import org.restlet.engine.connector.HttpsServerHelper; + +/** Tests TLS, keep-alive pings, handler failures and client settings. */ +class WebSocketSessionTestCase { + + private static final String KEYSTORE_PASSWORD = "testtest"; + + private Server server; + + private Path keystore; + + @BeforeEach + void setUp() throws IOException { + Engine.clearThreadLocalVariables(); + Engine.register(false); + Engine.getInstance().getRegisteredServers().add(new HttpServerHelper(null)); + Engine.getInstance().getRegisteredServers().add(new HttpsServerHelper(null)); + keystore = Files.createTempFile("websocket", ".p12"); + try (InputStream in = getClass().getResourceAsStream("dummy.p12")) { + Files.copy(in, keystore, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } + + @AfterEach + void tearDown() throws Exception { + if (server != null) { + server.stop(); + } + Files.deleteIfExists(keystore); + Engine.clearThreadLocalVariables(); + Engine.register(); + } + + private static WebSocketResource resource(WebSocketHandler handler) { + return new WebSocketResource() { + @Override + protected WebSocketHandler accept(Request request, Response response) { + return handler; + } + }; + } + + private static final WebSocketHandler ECHO = + session -> { + WebSocketMessage message; + while ((message = session.receive()) != null) { + session.send(message); + } + }; + + private int start(Protocol protocol, Context context, WebSocketHandler handler) + throws Exception { + server = new Server(context, protocol, 0, resource(handler)); + server.start(); + return server.getActualPort(); + } + + private SSLContext trustingContext() throws Exception { + KeyStore trustStore = KeyStore.getInstance("PKCS12"); + try (InputStream in = Files.newInputStream(keystore)) { + trustStore.load(in, KEYSTORE_PASSWORD.toCharArray()); + } + TrustManagerFactory tmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, tmf.getTrustManagers(), null); + return context; + } + + @Test + void echoesOverTls() throws Exception { + Context context = new Context(); + context.getParameters().add("keyStorePath", keystore.toString()); + context.getParameters().add("keyStorePassword", KEYSTORE_PASSWORD); + context.getParameters().add("keyPassword", KEYSTORE_PASSWORD); + context.getParameters().add("keyStoreType", "PKCS12"); + int port = start(Protocol.HTTPS, context, ECHO); + + try (WebSocketClient client = + WebSocketClient.builder() + .sslContext(trustingContext()) + .hostnameVerification(true) + .maxMessageSize(1024) + .build(); + WebSocketSession session = client.connect("wss://localhost:" + port + "/")) { + session.sendText("secure"); + assertEquals("secure", session.receive().getText()); + } + } + + @Test + void idleSessionsArePingedAndKeptAliveByPongs() throws Exception { + Context context = new Context(); + context.getParameters().add("connector.idleTimeout", "100"); + int port = start(Protocol.HTTP, context, ECHO); + + try (WebSocketClient client = + WebSocketClient.builder().idleTimeout(Duration.ofMillis(100)).build(); + WebSocketSession session = client.connect("ws://localhost:" + port + "/")) { + // Several idle periods: both sides ping, and the pongs keep the session open. + Thread.sleep(600); + assertTrue(session.isOpen()); + session.sendText("still here"); + assertEquals("still here", session.receive().getText()); + } + } + + @Test + void handlerFailureClosesWithInternalError() throws Exception { + int port = + start( + Protocol.HTTP, + new Context(), + session -> { + throw new IllegalStateException("boom"); + }); + + try (WebSocketClient client = WebSocketClient.create()) { + WebSocketSession session = client.connect("ws://localhost:" + port + "/"); + assertNull(session.receive()); + assertEquals(WebSocketSession.INTERNAL_ERROR, session.getCloseCode()); + assertNull(session.getSubprotocol()); + } + } + + @Test + void connectionFailuresAreReported() throws Exception { + int freePort; + try (ServerSocket socket = new ServerSocket(0)) { + freePort = socket.getLocalPort(); + } + try (WebSocketClient client = + WebSocketClient.builder().connectTimeout(Duration.ofSeconds(2)).build()) { + assertThrows( + ConnectException.class, + () -> client.connect("ws://localhost:" + freePort + "/")); + assertThrows( + IllegalArgumentException.class, + () -> client.connect("http://localhost:" + freePort + "/")); + assertThrows(IllegalArgumentException.class, () -> client.connect("ws:///nohost")); + } + } + + @Test + void builderValidatesSettings() { + WebSocketClient.Builder builder = WebSocketClient.builder(); + assertThrows(IllegalArgumentException.class, () -> builder.maxMessageSize(0)); + assertThrows( + IllegalArgumentException.class, () -> builder.idleTimeout(Duration.ofSeconds(-1))); + assertThrows( + IllegalArgumentException.class, + () -> builder.connectTimeout(Duration.ofSeconds(-1))); + } + + @Test + void messagesHaveValueSemantics() { + assertEquals(WebSocketMessage.text("a"), WebSocketMessage.text("a")); + assertEquals(WebSocketMessage.text("a").hashCode(), WebSocketMessage.text("a").hashCode()); + assertNotEquals(WebSocketMessage.text("a"), WebSocketMessage.binary(new byte[] {'a'})); + assertNotEquals(WebSocketMessage.text("a"), "a"); + assertEquals("text[1 chars]", WebSocketMessage.text("a").toString()); + } + + @Test + void invalidServerParametersAreRejected() throws Exception { + Context context = new Context(); + context.getParameters().add("websocket.maxMessageSize", "0"); + int port = start(Protocol.HTTP, context, ECHO); + + try (WebSocketClient client = WebSocketClient.create()) { + assertThrows(IOException.class, () -> client.connect("ws://localhost:" + port + "/")); + } + } +} diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/WebSocketTestCase.java b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java similarity index 97% rename from org.restlet/src/test/java/org/restlet/engine/connector/WebSocketTestCase.java rename to org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java index 9fb264141f..1f32d60c56 100644 --- a/org.restlet/src/test/java/org/restlet/engine/connector/WebSocketTestCase.java +++ b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java @@ -6,7 +6,7 @@ *

* Restlet is a registered trademark of QlikTech International AB. */ -package org.restlet.engine.connector; +package org.restlet.ext.websocket; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -43,13 +43,10 @@ import org.restlet.data.Protocol; import org.restlet.data.Status; import org.restlet.engine.Engine; +import org.restlet.engine.connector.HttpClientHelper; +import org.restlet.engine.connector.HttpServerHelper; import org.restlet.routing.Router; import org.restlet.security.Authenticator; -import org.restlet.websocket.WebSocketClient; -import org.restlet.websocket.WebSocketHandler; -import org.restlet.websocket.WebSocketMessage; -import org.restlet.websocket.WebSocketResource; -import org.restlet.websocket.WebSocketSession; class WebSocketTestCase { @@ -258,7 +255,7 @@ void plainRequestsToAWebSocketResourceAreRejected() throws Exception { @Test void enforcesTheOpenSessionLimit() throws Exception { Context context = new Context(); - context.getParameters().add("server.maxWebSockets", "1"); + context.getParameters().add("websocket.maxSessions", "1"); String base = start(resource(ECHO), context); WebSocketSession first = client.connect(base + "/"); diff --git a/org.restlet/src/test/java/org/restlet/websocket/WebSocketUpgradeTestCase.java b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketUpgradeTestCase.java similarity index 99% rename from org.restlet/src/test/java/org/restlet/websocket/WebSocketUpgradeTestCase.java rename to org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketUpgradeTestCase.java index 6ed54e2cc5..4f08b5b4c1 100644 --- a/org.restlet/src/test/java/org/restlet/websocket/WebSocketUpgradeTestCase.java +++ b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketUpgradeTestCase.java @@ -6,7 +6,7 @@ *

* Restlet is a registered trademark of QlikTech International AB. */ -package org.restlet.websocket; +package org.restlet.ext.websocket; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/org.restlet.ext.websocket/src/test/resources/org/restlet/ext/websocket/dummy.p12 b/org.restlet.ext.websocket/src/test/resources/org/restlet/ext/websocket/dummy.p12 new file mode 100644 index 0000000000000000000000000000000000000000..94519e717d66bec98a3aa5a44bb3f04bf89e0373 GIT binary patch literal 2726 zcma);XE+;-7RQrFY^AM|RP7QJjgcC!O^6t^m8v~T?M*3SM~xyz&8jU{(W|2 zMp3h+W=M^<_qq4!``l0WemKv0&hLNz=i~o52rP902uOy&Qfrb^2}Gfzh;%>-U_O>w z7L28q`i+qYEQIi15+nzVg=GH5_}}%6oaR3+T52FD9}7|V4P_Aae;g1xgb70OpOF@E z3(U9|xTdxaFB2W7pvEmN+^aemY@q>x;0z!j2ZENI^4}LhR1g4ym7K~l3JtU)0|N!X z4A*zPUTZzAd;w4&)->2FO$KAZquJq!M}dg&WWWp^d)N=)cRxNxZ^F6%rc{%A6BkI< z{xA{6QfVcAA5cZG_14yRQj;;7i{5@W=iJl_NT|EYqWgAmM>VF61%a{BNaXwrlgy3} zm^~bf6oe0v%CQ%W$U7-TBKhEJa547fBI%kq;oZ?Gu1qNky~DPK%XqiG8ZX#FX4ePa z)N$(!KQLFcOXKSYU3KIpx_-W1w8mzo8!Tle4d!ekV)?3DIu4E51%}Y}5MH0qYK_$5 z8j)7k9b$3gCJY(jWc7V?awtuF$T9C;OA>V1tAssm+>ieaaa1KjyU9CI=jsNa^&_ zpWu&OFw+?_n7N%?ZPtGru;HszZ>d7VW|NjXI3NGoGCb&>S^cY|F5)WOb(oEv*2;>iXmC2lEH_Wk!D|EPzYz&ch|COX!QK~_C?OS%Pd^q0x328cUmHgzLd4-oCG;hD9E-&A=$rEJc|X^;cK(FSOTS@QaUOai5N>9;R3< zdr;A(>aF(8QE9fDF)1dRw4d%>V#dGhP?HuYudTSe`<3KiKldvz{UW*8`7Ny;kV?#8 zwG2574_{xj>&hX!n!@iKQ`a{D`U&5cs^oI*NPw`pqk&OxTvMcwL3}Wa#*)!99O!b>vVqlDWK6X! z;;cFY`Z(s%p$Bou6V5bHzDu^Dbt{L3@-zcvnHM%8=!8bJ7KVpVEpM9-M@W*!c^{2s)=6y{t*?t;ZfKjtr6v(SSOWDB*C)tMvnn9%aNgc6eGH()4V_C1ciM` z5+qAEoZMWwx20rY+&MHyv7R>GS;^+|e5kWh&{XOXKNHs1(}ZbI+W z*U`G|U|{<(jTfCwRZ`!YiQGdRxHjN>1oUI9Tcj<0i5ByUd6mPaVXT7TH*)qAT%ZK( z;V2zZDt_O}YPIb&wnajcGqwKW*R+YyesggLWX@bk+8M?_qZqSR5cQ%n9+PtCVu|&5 z>lx35h#Ug;CoZWNMQGWm0WN?5z$1ViAOOMnk919h5q!nK(Z!BKL_!=PCN3c&DK05` z2Z05L{aK{G$N>S4i6z5c=ElieA6x3E|rQWr}w_{I>f4gmjta z=XIjKC~m9Seh5XTmlld8`x&3zy&i>g3#=%ezkt@5mVf6XF2k@M(@EXp_c_r)^O|Cn zXqS>|_Pd{+&6Y0%0&=%X8dtBT3el4+lxyADd+_*Zg7tyRndqhBb$>k!f;*R2st+Dq zeXBy|L`8W|aO3uTfhT*FtozQ&Fjv`Ft`OtX8y*7bC#C?K3Q&iwiXwk;ROxn!7R!2p zb3?VA)x!!uq8DtGq7$=zRFwo{`#{=^f z=1l1g&60NAoAx4m9x#jevH&xErae6tSAZEWapCmN`@% z6bAf(4KMW9UQ!p1!%=K;o(RgdFwF1E_26)MBfe8i0k3_Ekaxx|Jddtyy;%|2X2YBX zUY`Y*BfU5#CzjuZrl_a=bchR6AKRu^epMzqU3i__CYv9d2PP@y+Dd(yS7Q57pgcj& zl)!5nG^hkdF)rv{Gfh`=1mCjlNMcwyny70!RC-1|C+vbxC!(uJ3 zljs!IYPnN5T_GrbE^hS~M1DDIlt8!4Y4@cGzKZ_bm*=@J|=^j;lGDoEVyAU4< zr#tJT%wWa2yiXIJW3MVqBX5Fon~|>cy+oe*C33N~xm6lX7E-L``c8eQ>_C_6a#50_ z{!JI#p~~|5=aGplBY8CO*P2PdAR8WA$10}Ms9kTXdy(6vSuTDGhhmHtJ z1V6{W73BjVgEHP?HCV0)({TC3>K}0*EcVj!ybAT&t(g(r&HHM#eVUEfm~wDBtgIEd z0*`#G_#~mcF>VB=0*$VpdG>tQuYKVOuJrsnuxEU}3AxnwR+fwBg!F`R&AM=6x1IXK z_);w;wz1IbI7cD1GbnGWc8EXpFipDuiY|r>nt5AAG8RR6FV$wIRosk$Oh1t>&~rNA zCT3Z{ILCP*M`Lq5!^td35T#|_+ptt< + * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.netty; + +/** + * Takes over an HTTP/1.1 server connection after a "101 Switching Protocols" response, for example + * to speak WebSocket. Register it as a response attribute named {@link #ATTRIBUTE} and set the + * response status to 101: once the response leaves the Restlet chain, the Netty server connector + * removes its HTTP handlers from the connection pipeline (keeping the HTTP codec) and calls {@link + * #upgrade(NettyUpgradeContext)} instead of writing the response. The upgrade is then responsible + * for the handshake response and for the rest of the connection. + * + *

Upgrades are only supported on HTTP/1.1 connections; on HTTP/2 and HTTP/3 the connector + * answers 501 (Not Implemented). + * + *

This is an engine extension point, meant for Restlet extensions. + */ +@FunctionalInterface +public interface NettyConnectionUpgrade { + + /** Name of the response attribute holding the connection upgrade. */ + String ATTRIBUTE = "org.restlet.engine.netty.connectionUpgrade"; + + /** + * Takes over the connection. Called on the thread of the call, which must not be blocked for + * the lifetime of the connection. If it throws, the connection is closed. + * + * @param context The upgrade context. + * @throws Exception If the upgrade fails. + */ + void upgrade(NettyUpgradeContext context) throws Exception; +} diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java index d9023add39..439cb3d9e1 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java @@ -17,6 +17,8 @@ import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelPipeline; import io.netty.handler.codec.http.DefaultHttpContent; import io.netty.handler.codec.http.DefaultHttpResponse; import io.netty.handler.codec.http.DefaultLastHttpContent; @@ -26,6 +28,8 @@ import io.netty.handler.codec.http.HttpRequest; import io.netty.handler.codec.http.HttpResponse; import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpServerKeepAliveHandler; +import io.netty.handler.codec.http.HttpServerUpgradeHandler; import io.netty.handler.codec.http.HttpUtil; import io.netty.handler.codec.http.HttpVersion; import io.netty.handler.codec.http.LastHttpContent; @@ -64,7 +68,6 @@ import org.restlet.representation.Representation; import org.restlet.service.ConnectorService; import org.restlet.util.Series; -import org.restlet.websocket.WebSocketUpgrade; /** * Server exchange bridging a Netty HTTP request (HTTP/1.1, or one HTTP/2 or HTTP/3 stream converted @@ -357,27 +360,16 @@ private InputStream peek(InputStream stream) { * @param response The Restlet response. */ void commit(Response response) { - WebSocketUpgrade upgrade = WebSocketUpgrade.get(response); - if (upgrade != null && Status.INFO_SWITCHING_PROTOCOL.equals(response.getStatus())) { - if (kind != StreamKind.HTTP1) { - response.setStatus( - Status.SERVER_ERROR_NOT_IMPLEMENTED, - "WebSocket is only supported over HTTP/1.1"); - } else if (!helper.tryAcquireWebSocket()) { - response.setStatus(Status.SERVER_ERROR_SERVICE_UNAVAILABLE); - } else { - completed = true; - responseHeadWritten = true; - requestBody.close(); - try { - NettyWebSocketUpgrader.upgrade(helper, channel, request, confidential, upgrade); - } finally { - if (response.getOnSent() != null) { - response.getOnSent().handle(response.getRequest(), response); - } - } + if (response.getAttributes().get(NettyConnectionUpgrade.ATTRIBUTE) + instanceof NettyConnectionUpgrade upgrade + && Status.INFO_SWITCHING_PROTOCOL.equals(response.getStatus())) { + if (kind == StreamKind.HTTP1) { + upgrade(response, upgrade); return; } + response.setStatus( + Status.SERVER_ERROR_NOT_IMPLEMENTED, + "Protocol upgrades are only supported over HTTP/1.1"); response.setEntity(null); } @@ -405,6 +397,38 @@ void commit(Response response) { } } + /** + * Hands the HTTP/1.1 connection over to a protocol upgrade instead of writing the response. + * + * @param response The "101 Switching Protocols" response. + * @param upgrade The connection upgrade. + */ + private void upgrade(Response response, NettyConnectionUpgrade upgrade) { + completed = true; + responseHeadWritten = true; + requestBody.close(); + try { + ChannelPipeline pipeline = channel.pipeline(); + for (Class type : + List.of( + HttpServerKeepAliveHandler.class, + HttpServerUpgradeHandler.class, + NettyServerHandler.class)) { + if (pipeline.get(type) != null) { + pipeline.remove(type); + } + } + upgrade.upgrade(new NettyUpgradeContext(channel, request, confidential, helper)); + } catch (Exception | LinkageError e) { + getLogger().log(Level.WARNING, "Unable to upgrade the connection", e); + channel.close(); + } finally { + if (response.getOnSent() != null) { + response.getOnSent().handle(response.getRequest(), response); + } + } + } + private static boolean isEventStream(Representation entity) { return entity != null && entity.getMediaType() != null diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHelper.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHelper.java index f9a1edbab7..f9f32d5a66 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHelper.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHelper.java @@ -117,20 +117,6 @@ * When 0, there is no limit. * * - * server.maxWebSockets - * int - * 0 - * Maximum number of open WebSocket sessions; extra upgrades receive a 503 response. When 0, - * there is no limit. - * - * - * websocket.maxMessageSize - * int - * 65536 - * Maximum size in bytes of a received WebSocket message; larger messages close the session - * with status 1009. - * - * * eventLoop.bossThreads * int * 1 @@ -225,9 +211,6 @@ public abstract class NettyServerHelper extends ServerHelper { /** Limits concurrent SSE streams, or null when unlimited. */ private volatile Semaphore eventStreamPermits; - /** Limits open WebSocket sessions, or null when unlimited. */ - private volatile Semaphore webSocketPermits; - /** The number of calls in progress. */ private final AtomicInteger activeCalls = new AtomicInteger(); @@ -539,20 +522,6 @@ void releaseEventStream() { } } - /** Tries to acquire a permit for a WebSocket session. */ - boolean tryAcquireWebSocket() { - Semaphore permits = webSocketPermits; - return !stopping && (permits == null || permits.tryAcquire()); - } - - /** Releases an acquired WebSocket session permit. */ - void releaseWebSocket() { - Semaphore permits = webSocketPermits; - if (permits != null) { - permits.release(); - } - } - /** * Returns the executor running Restlet calls. * @@ -577,8 +546,6 @@ public synchronized void start() throws Exception { callPermits = (maxCalls > 0) ? new Semaphore(maxCalls) : null; int maxEventStreams = getServerMaxEventStreams(); eventStreamPermits = (maxEventStreams > 0) ? new Semaphore(maxEventStreams) : null; - int maxWebSockets = getServerMaxWebSockets(); - webSocketPermits = (maxWebSockets > 0) ? new Semaphore(maxWebSockets) : null; callExecutor = CallExecutors.create( "restlet-server", @@ -893,35 +860,6 @@ public int getServerMaxEventStreams() { return result; } - /** - * Maximum number of open WebSocket sessions. Defaults to 0 (unlimited). - * - * @return Maximum number of open WebSocket sessions. - */ - public int getServerMaxWebSockets() { - int result = - Integer.parseInt(getHelpedParameters().getFirstValue("server.maxWebSockets", "0")); - if (result < 0) { - throw new IllegalArgumentException("'server.maxWebSockets' cannot be negative"); - } - return result; - } - - /** - * Maximum size in bytes of a received WebSocket message. Defaults to 65536. - * - * @return Maximum WebSocket message size. - */ - public int getWebSocketMaxMessageSize() { - int result = - Integer.parseInt( - getHelpedParameters().getFirstValue("websocket.maxMessageSize", "65536")); - if (result <= 0) { - throw new IllegalArgumentException("'websocket.maxMessageSize' must be positive"); - } - return result; - } - /** * Number of event loop threads accepting connections. Defaults to 1. * diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyUpgradeContext.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyUpgradeContext.java new file mode 100644 index 0000000000..d5d92baeb4 --- /dev/null +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyUpgradeContext.java @@ -0,0 +1,106 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.netty; + +import io.netty.channel.Channel; +import io.netty.handler.codec.http.HttpRequest; +import java.util.Map; +import java.util.logging.Logger; +import org.restlet.data.Parameter; +import org.restlet.util.Series; + +/** + * Connection handed to a {@link NettyConnectionUpgrade}. It exposes Netty types: extensions using + * it must require the Netty modules themselves. + */ +@SuppressWarnings("exports") +public final class NettyUpgradeContext { + + private final Channel channel; + + private final HttpRequest request; + + private final boolean confidential; + + private final NettyServerHelper helper; + + NettyUpgradeContext( + Channel channel, HttpRequest request, boolean confidential, NettyServerHelper helper) { + this.channel = channel; + this.request = request; + this.confidential = confidential; + this.helper = helper; + } + + /** + * Returns the HTTP/1.1 connection. Its pipeline still holds the HTTP server codec and the idle + * state handler, if any; the Restlet HTTP handlers were removed. Reading is on demand + * (auto-read is off). + * + * @return The connection. + */ + public Channel getChannel() { + return channel; + } + + /** + * Returns the head of the upgrade request. + * + * @return The upgrade request head. + */ + public HttpRequest getRequest() { + return request; + } + + /** + * Indicates whether the connection is secured with TLS. + * + * @return True if the connection is secured with TLS. + */ + public boolean isConfidential() { + return confidential; + } + + /** + * Returns the parameters of the server connector. + * + * @return The parameters of the server connector. + */ + public Series getParameters() { + return helper.getHelpedParameters(); + } + + /** + * Returns the attributes of the server connector, shared by all its connections, for example to + * keep per-server state. + * + * @return The attributes of the server connector. + */ + public Map getAttributes() { + return helper.getAttributes(); + } + + /** + * Returns the connection idle timeout of the server connector. + * + * @return The idle timeout in milliseconds, 0 for none. + */ + public long getIdleTimeout() { + return helper.getConnectorIdleTimeout(); + } + + /** + * Returns the logger of the server connector. + * + * @return The logger. + */ + public Logger getLogger() { + return helper.getLogger(); + } +} diff --git a/org.restlet/src/main/java/org/restlet/websocket/package-info.java b/org.restlet/src/main/java/org/restlet/websocket/package-info.java deleted file mode 100644 index 1fe1eecdca..0000000000 --- a/org.restlet/src/main/java/org/restlet/websocket/package-info.java +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Copyright 2005-2026 Qlik - * - *

The content of this file is subject to the terms of the Apache 2.0 open source license - * available at https://www.opensource.org/licenses/apache-2.0 - * - *

Restlet is a registered trademark of QlikTech International AB. - */ -/** - * WebSocket support (experimental): {@link org.restlet.websocket.WebSocketResource} accepts - * upgrades on the HTTP server connector, {@link org.restlet.websocket.WebSocketClient} opens client - * sessions, and both use the blocking {@link org.restlet.websocket.WebSocketSession}. - */ -package org.restlet.websocket; diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/NettyConnectionUpgradeTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/NettyConnectionUpgradeTestCase.java new file mode 100644 index 0000000000..4e21a021ab --- /dev/null +++ b/org.restlet/src/test/java/org/restlet/engine/connector/NettyConnectionUpgradeTestCase.java @@ -0,0 +1,147 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.connector; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelFutureListener; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpVersion; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.Server; +import org.restlet.data.Protocol; +import org.restlet.data.Status; +import org.restlet.engine.Engine; +import org.restlet.engine.netty.NettyConnectionUpgrade; +import org.restlet.engine.netty.NettyUpgradeContext; + +/** Tests the connection upgrade hook of the Netty server connector. */ +class NettyConnectionUpgradeTestCase { + + private Server server; + + @BeforeEach + void setUp() { + Engine.clearThreadLocalVariables(); + Engine.register(false); + Engine.getInstance().getRegisteredServers().add(new HttpServerHelper(null)); + } + + @AfterEach + void tearDown() throws Exception { + if (server != null) { + server.stop(); + } + Engine.clearThreadLocalVariables(); + Engine.register(); + } + + private int start(NettyConnectionUpgrade upgrade, String protocol) throws Exception { + Context context = new Context(); + if (protocol != null) { + context.getParameters().add("http.transport.protocol", protocol); + } + server = + new Server( + context, + Protocol.HTTP, + 0, + new Restlet() { + @Override + public void handle(Request request, Response response) { + response.getAttributes() + .put(NettyConnectionUpgrade.ATTRIBUTE, upgrade); + response.setStatus(Status.INFO_SWITCHING_PROTOCOL); + } + }); + server.start(); + return server.getActualPort(); + } + + private static String exchange(int port) throws Exception { + try (Socket socket = new Socket("localhost", port)) { + OutputStream out = socket.getOutputStream(); + out.write( + ("GET /up HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\n" + + "Upgrade: test\r\n\r\n") + .getBytes(StandardCharsets.US_ASCII)); + out.flush(); + BufferedReader in = + new BufferedReader( + new InputStreamReader( + socket.getInputStream(), StandardCharsets.US_ASCII)); + StringBuilder result = new StringBuilder(); + String line; + while ((line = in.readLine()) != null) { + result.append(line).append('\n'); + } + return result.toString(); + } + } + + @Test + void handsTheConnectionOverToTheUpgrade() throws Exception { + AtomicReference seen = new AtomicReference<>(); + int port = + start( + context -> { + seen.set(context); + FullHttpResponse response = + new DefaultFullHttpResponse( + HttpVersion.HTTP_1_1, + HttpResponseStatus.SWITCHING_PROTOCOLS, + Unpooled.EMPTY_BUFFER); + response.headers().set("Upgrade", "test"); + context.getChannel() + .writeAndFlush(response) + .addListener(ChannelFutureListener.CLOSE); + }, + null); + + String reply = exchange(port); + + assertTrue(reply.startsWith("HTTP/1.1 101 Switching Protocols"), reply); + NettyUpgradeContext context = seen.get(); + assertEquals("/up", context.getRequest().uri()); + assertFalse(context.isConfidential()); + assertEquals(30000, context.getIdleTimeout()); + assertTrue(context.getParameters().isEmpty()); + context.getAttributes().put("seen", true); + assertTrue(context.getLogger() != null); + } + + @Test + void failingUpgradeClosesTheConnection() throws Exception { + int port = + start( + context -> { + throw new IllegalStateException("refused"); + }, + null); + + assertEquals("", exchange(port)); + } +} diff --git a/pom.xml b/pom.xml index 69f042a2db..8170543aba 100644 --- a/pom.xml +++ b/pom.xml @@ -85,6 +85,7 @@ org.restlet.ext.spring org.restlet.ext.thymeleaf org.restlet.ext.velocity + org.restlet.ext.websocket org.restlet.ext.xml From f4a9b3bb51b677f28b79d4c0b1607b1a3cf61081 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:38:45 -0400 Subject: [PATCH 3/7] refactor: rename WebSocketResource to WebSocketUpgrader Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- changes.md | 2 +- ...a => NettyWebSocketConnectionUpgrade.java} | 4 ++-- .../ext/websocket/WebSocketUpgrade.java | 4 +++- ...etResource.java => WebSocketUpgrader.java} | 6 ++--- .../restlet/ext/websocket/package-info.java | 2 +- .../websocket/WebSocketSessionTestCase.java | 6 ++--- .../ext/websocket/WebSocketTestCase.java | 22 +++++++++---------- .../websocket/WebSocketUpgradeTestCase.java | 4 ++-- 8 files changed, 26 insertions(+), 24 deletions(-) rename org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/{NettyWebSocketUpgrader.java => NettyWebSocketConnectionUpgrade.java} (98%) rename org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/{WebSocketResource.java => WebSocketUpgrader.java} (94%) diff --git a/changes.md b/changes.md index 9b97fb34d0..85fc3356b3 100644 --- a/changes.md +++ b/changes.md @@ -67,7 +67,7 @@ Changes log comment heartbeats and reconnects honoring `retry:` and `Last-Event-ID`. The `server.maxEventStreams` parameter limits concurrent event streams. - Added the experimental `org.restlet.ext.websocket` extension, on HTTP/1.1 upgrades. - `WebSocketResource` (or `WebSocketUpgrade.accept`) accepts upgrades that went through + `WebSocketUpgrader` (or `WebSocketUpgrade.accept`) accepts upgrades that went through the normal routing, filters and authenticators, and runs a `WebSocketHandler` on its own virtual thread with a blocking `WebSocketSession` (`receive`, `sendText`, `sendBinary`, `close`). `WebSocketClient` opens client sessions on "ws" and "wss" URIs. The server diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketUpgrader.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketConnectionUpgrade.java similarity index 98% rename from org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketUpgrader.java rename to org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketConnectionUpgrade.java index ac17a8d1e6..9a8b083739 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketUpgrader.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketConnectionUpgrade.java @@ -38,7 +38,7 @@ * Switches an HTTP/1.1 server connection to WebSocket after a Restlet accepted the upgrade, then * runs the session handler on its own virtual thread. */ -final class NettyWebSocketUpgrader implements NettyConnectionUpgrade { +final class NettyWebSocketConnectionUpgrade implements NettyConnectionUpgrade { /** Name of the server connector attribute holding the session permits. */ private static final String PERMITS_ATTRIBUTE = "org.restlet.ext.websocket.permits"; @@ -53,7 +53,7 @@ final class NettyWebSocketUpgrader implements NettyConnectionUpgrade { * * @param upgrade The accepted upgrade. */ - NettyWebSocketUpgrader(WebSocketUpgrade upgrade) { + NettyWebSocketConnectionUpgrade(WebSocketUpgrade upgrade) { this.upgrade = upgrade; } diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketUpgrade.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketUpgrade.java index 2ce6afeaf6..27790a9668 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketUpgrade.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketUpgrade.java @@ -100,7 +100,9 @@ public static void accept(Response response, WebSocketHandler handler, String su WebSocketUpgrade upgrade = new WebSocketUpgrade(handler, subprotocol); response.getAttributes().put(ATTRIBUTE, upgrade); response.getAttributes() - .put(NettyConnectionUpgrade.ATTRIBUTE, new NettyWebSocketUpgrader(upgrade)); + .put( + NettyConnectionUpgrade.ATTRIBUTE, + new NettyWebSocketConnectionUpgrade(upgrade)); response.setStatus(Status.INFO_SWITCHING_PROTOCOL); response.setEntity(null); } diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketResource.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketUpgrader.java similarity index 94% rename from org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketResource.java rename to org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketUpgrader.java index ba30ba9058..28b9b644e5 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketResource.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketUpgrader.java @@ -21,10 +21,10 @@ * authenticators in front of it apply to the upgrade request. Requests that do not ask for an * upgrade receive a 400 (Bad Request) response. */ -public abstract class WebSocketResource extends Restlet { +public abstract class WebSocketUpgrader extends Restlet { /** Constructor. */ - protected WebSocketResource() { + protected WebSocketUpgrader() { super(); } @@ -33,7 +33,7 @@ protected WebSocketResource() { * * @param context The context. */ - protected WebSocketResource(Context context) { + protected WebSocketUpgrader(Context context) { super(context); } diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/package-info.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/package-info.java index befb67dc05..f51cece052 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/package-info.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/package-info.java @@ -7,7 +7,7 @@ *

Restlet is a registered trademark of QlikTech International AB. */ /** - * WebSocket support (experimental): {@link org.restlet.ext.websocket.WebSocketResource} accepts + * WebSocket support (experimental): {@link org.restlet.ext.websocket.WebSocketUpgrader} accepts * upgrades on the Netty HTTP server connector of the core, {@link * org.restlet.ext.websocket.WebSocketClient} opens client sessions, and both use the blocking * {@link org.restlet.ext.websocket.WebSocketSession}. diff --git a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketSessionTestCase.java b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketSessionTestCase.java index 248656b509..aefed28f32 100644 --- a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketSessionTestCase.java +++ b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketSessionTestCase.java @@ -67,8 +67,8 @@ void tearDown() throws Exception { Engine.register(); } - private static WebSocketResource resource(WebSocketHandler handler) { - return new WebSocketResource() { + private static WebSocketUpgrader upgrader(WebSocketHandler handler) { + return new WebSocketUpgrader() { @Override protected WebSocketHandler accept(Request request, Response response) { return handler; @@ -86,7 +86,7 @@ protected WebSocketHandler accept(Request request, Response response) { private int start(Protocol protocol, Context context, WebSocketHandler handler) throws Exception { - server = new Server(context, protocol, 0, resource(handler)); + server = new Server(context, protocol, 0, upgrader(handler)); server.start(); return server.getActualPort(); } diff --git a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java index 1f32d60c56..f60324cd1b 100644 --- a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java +++ b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java @@ -74,8 +74,8 @@ void tearDown() throws Exception { Engine.register(); } - private static WebSocketResource resource(WebSocketHandler handler) { - return new WebSocketResource() { + private static WebSocketUpgrader upgrader(WebSocketHandler handler) { + return new WebSocketUpgrader() { @Override protected WebSocketHandler accept(Request request, Response response) { return handler; @@ -104,7 +104,7 @@ private String start(Restlet root, Context context) throws Exception { @Test void echoesTextAndBinaryMessagesWithSubprotocol() throws Exception { - String base = start(resource(ECHO), new Context()); + String base = start(upgrader(ECHO), new Context()); try (WebSocketSession session = client.connect(URI.create(base + "/echo"), List.of("other", "chat"), Map.of())) { @@ -126,7 +126,7 @@ void echoesTextAndBinaryMessagesWithSubprotocol() throws Exception { void serverHandlerReturningClosesTheSessionNormally() throws Exception { String base = start( - resource( + upgrader( session -> { session.sendText("bye"); }), @@ -146,7 +146,7 @@ void clientCloseIsReportedToTheServer() throws Exception { CountDownLatch closed = new CountDownLatch(1); String base = start( - resource( + upgrader( session -> { assertNull(session.receive()); serverCode.set(session.getCloseCode()); @@ -169,7 +169,7 @@ void clientCloseIsReportedToTheServer() throws Exception { void largerMessagesThanTheLimitCloseTheSession() throws Exception { Context context = new Context(); context.getParameters().add("websocket.maxMessageSize", "16"); - String base = start(resource(ECHO), context); + String base = start(upgrader(ECHO), context); WebSocketSession session = client.connect(base + "/"); session.sendText("x".repeat(64)); @@ -184,7 +184,7 @@ void routingAndAuthenticationApplyToTheUpgrade() throws Exception { @Override public Restlet createInboundRoot() { Router router = new Router(getContext()); - router.attach("/echo", resource(ECHO)); + router.attach("/echo", upgrader(ECHO)); Authenticator guard = new Authenticator(getContext()) { @Override @@ -239,8 +239,8 @@ protected boolean authenticate( } @Test - void plainRequestsToAWebSocketResourceAreRejected() throws Exception { - String base = start(resource(ECHO), new Context()); + void plainRequestsToAWebSocketUpgraderAreRejected() throws Exception { + String base = start(upgrader(ECHO), new Context()); Client http = new Client(Protocol.HTTP); http.start(); try { @@ -256,7 +256,7 @@ void plainRequestsToAWebSocketResourceAreRejected() throws Exception { void enforcesTheOpenSessionLimit() throws Exception { Context context = new Context(); context.getParameters().add("websocket.maxSessions", "1"); - String base = start(resource(ECHO), context); + String base = start(upgrader(ECHO), context); WebSocketSession first = client.connect(base + "/"); IOException rejected = assertThrows(IOException.class, () -> client.connect(base + "/")); @@ -278,7 +278,7 @@ void enforcesTheOpenSessionLimit() throws Exception { @Test void interoperatesWithTheJdkWebSocketClient() throws Exception { - String base = start(resource(ECHO), new Context()); + String base = start(upgrader(ECHO), new Context()); CompletableFuture reply = new CompletableFuture<>(); HttpClient jdk = HttpClient.newHttpClient(); WebSocket ws = diff --git a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketUpgradeTestCase.java b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketUpgradeTestCase.java index 4f08b5b4c1..2fb2acb8d4 100644 --- a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketUpgradeTestCase.java +++ b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketUpgradeTestCase.java @@ -86,8 +86,8 @@ void acceptRejectsInvalidUpgrades() { @Test void resourceRejectsWhenNoHandler() { - WebSocketResource refusing = - new WebSocketResource() { + WebSocketUpgrader refusing = + new WebSocketUpgrader() { @Override protected WebSocketHandler accept(Request request, Response response) { return null; From b1d2d862f77eb8b5f292b45511176b5911edb638 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:46:59 -0400 Subject: [PATCH 4/7] refactor: move Netty WebSocket classes to an internal package org.restlet.ext.websocket.internal is not exported by the module. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- .../org/restlet/ext/websocket/WebSocketClient.java | 1 + .../org/restlet/ext/websocket/WebSocketUpgrade.java | 1 + .../NettyWebSocketConnectionUpgrade.java | 9 ++++++--- .../{ => internal}/NettyWebSocketConnector.java | 12 +++++++----- .../{ => internal}/NettyWebSocketSession.java | 4 +++- .../ext/websocket/internal/package-info.java | 13 +++++++++++++ 6 files changed, 31 insertions(+), 9 deletions(-) rename org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/{ => internal}/NettyWebSocketConnectionUpgrade.java (95%) rename org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/{ => internal}/NettyWebSocketConnector.java (96%) rename org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/{ => internal}/NettyWebSocketSession.java (98%) create mode 100644 org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/package-info.java diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java index ec6c4b70f7..62f42e75d8 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java @@ -17,6 +17,7 @@ import java.util.Objects; import java.util.logging.Logger; import javax.net.ssl.SSLContext; +import org.restlet.ext.websocket.internal.NettyWebSocketConnector; /** * Opens WebSocket client sessions ("ws" and "wss" URIs) over HTTP/1.1 upgrades. Each session uses diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketUpgrade.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketUpgrade.java index 27790a9668..2a7d60c20b 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketUpgrade.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketUpgrade.java @@ -18,6 +18,7 @@ import org.restlet.data.Method; import org.restlet.data.Status; import org.restlet.engine.netty.NettyConnectionUpgrade; +import org.restlet.ext.websocket.internal.NettyWebSocketConnectionUpgrade; /** * Accepts a WebSocket upgrade on a Restlet response. The upgrade request goes through the normal diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketConnectionUpgrade.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnectionUpgrade.java similarity index 95% rename from org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketConnectionUpgrade.java rename to org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnectionUpgrade.java index 9a8b083739..fec6c3968a 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketConnectionUpgrade.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnectionUpgrade.java @@ -6,7 +6,7 @@ *

* Restlet is a registered trademark of QlikTech International AB. */ -package org.restlet.ext.websocket; +package org.restlet.ext.websocket.internal; import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; @@ -32,13 +32,16 @@ import org.restlet.engine.Engine; import org.restlet.engine.netty.NettyConnectionUpgrade; import org.restlet.engine.netty.NettyUpgradeContext; +import org.restlet.ext.websocket.WebSocketHandler; +import org.restlet.ext.websocket.WebSocketSession; +import org.restlet.ext.websocket.WebSocketUpgrade; import org.restlet.util.Series; /** * Switches an HTTP/1.1 server connection to WebSocket after a Restlet accepted the upgrade, then * runs the session handler on its own virtual thread. */ -final class NettyWebSocketConnectionUpgrade implements NettyConnectionUpgrade { +public final class NettyWebSocketConnectionUpgrade implements NettyConnectionUpgrade { /** Name of the server connector attribute holding the session permits. */ private static final String PERMITS_ATTRIBUTE = "org.restlet.ext.websocket.permits"; @@ -53,7 +56,7 @@ final class NettyWebSocketConnectionUpgrade implements NettyConnectionUpgrade { * * @param upgrade The accepted upgrade. */ - NettyWebSocketConnectionUpgrade(WebSocketUpgrade upgrade) { + public NettyWebSocketConnectionUpgrade(WebSocketUpgrade upgrade) { this.upgrade = upgrade; } diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketConnector.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnector.java similarity index 96% rename from org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketConnector.java rename to org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnector.java index a6ded39fcc..6b3343d26d 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketConnector.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnector.java @@ -6,7 +6,7 @@ *

* Restlet is a registered trademark of QlikTech International AB. */ -package org.restlet.ext.websocket; +package org.restlet.ext.websocket.internal; import io.netty.bootstrap.Bootstrap; import io.netty.channel.Channel; @@ -50,13 +50,15 @@ import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLParameters; +import org.restlet.ext.websocket.WebSocketSession; /** * Netty implementation of the WebSocket client: one connection per session, HTTP/1.1 upgrade. * - *

This class is internal to the extension; use {@link WebSocketClient}. + *

This class is internal to the extension; use {@link + * org.restlet.ext.websocket.WebSocketClient}. */ -final class NettyWebSocketConnector implements AutoCloseable { +public final class NettyWebSocketConnector implements AutoCloseable { private final EventLoopGroup group; @@ -84,7 +86,7 @@ final class NettyWebSocketConnector implements AutoCloseable { * @param logger The logger. * @throws IOException If the SSL context cannot be created. */ - NettyWebSocketConnector( + public NettyWebSocketConnector( SSLContext sslContext, long connectTimeoutMs, long idleTimeoutMs, @@ -124,7 +126,7 @@ final class NettyWebSocketConnector implements AutoCloseable { * @return The open session. * @throws IOException If the connection or the handshake fails. */ - WebSocketSession connect(URI uri, List subprotocols, Map headers) + public WebSocketSession connect(URI uri, List subprotocols, Map headers) throws IOException { String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(); boolean secure; diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketSession.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketSession.java similarity index 98% rename from org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketSession.java rename to org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketSession.java index cd853e9c37..6cff20d400 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/NettyWebSocketSession.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketSession.java @@ -6,7 +6,7 @@ *

* Restlet is a registered trademark of QlikTech International AB. */ -package org.restlet.ext.websocket; +package org.restlet.ext.websocket.internal; import io.netty.buffer.ByteBufUtil; import io.netty.buffer.Unpooled; @@ -36,6 +36,8 @@ import java.util.concurrent.locks.ReentrantLock; import java.util.logging.Level; import java.util.logging.Logger; +import org.restlet.ext.websocket.WebSocketMessage; +import org.restlet.ext.websocket.WebSocketSession; /** * WebSocket session over a Netty channel whose pipeline decodes WebSocket frames and aggregates diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/package-info.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/package-info.java new file mode 100644 index 0000000000..541fea0652 --- /dev/null +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/package-info.java @@ -0,0 +1,13 @@ +/** + * Copyright 2005-2026 Qlik + * + *

The content of this file is subject to the terms of the Apache 2.0 open source license + * available at https://www.opensource.org/licenses/apache-2.0 + * + *

Restlet is a registered trademark of QlikTech International AB. + */ +/** + * Netty implementation of the WebSocket extension. This package is not exported by the module and + * is not part of the API. + */ +package org.restlet.ext.websocket.internal; From 032295d0684cd0c095e5c98532bb812ea6f2da0b Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:45:50 -0400 Subject: [PATCH 5/7] feat: add WebSocket compression and frame size limit Negotiate permessage-deflate (RFC 7692) on the server and the client, cap inflated messages by the message size, and add a separate frame size limit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- changes.md | 6 +- .../src/main/java/module-info.java | 1 + .../ext/websocket/WebSocketClient.java | 33 +++ .../ext/websocket/WebSocketSession.java | 6 +- .../NettyWebSocketConnectionUpgrade.java | 84 ++++++- .../internal/NettyWebSocketConnector.java | 32 ++- .../internal/NettyWebSocketSession.java | 9 + .../restlet/ext/websocket/package-info.java | 16 +- .../WebSocketCompressionTestCase.java | 208 ++++++++++++++++++ 9 files changed, 382 insertions(+), 13 deletions(-) create mode 100644 org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketCompressionTestCase.java diff --git a/changes.md b/changes.md index 85fc3356b3..6b3e16d514 100644 --- a/changes.md +++ b/changes.md @@ -71,8 +71,10 @@ Changes log the normal routing, filters and authenticators, and runs a `WebSocketHandler` on its own virtual thread with a blocking `WebSocketSession` (`receive`, `sendText`, `sendBinary`, `close`). `WebSocketClient` opens client sessions on "ws" and "wss" URIs. The server - answers pings, pings idle sessions, and supports the "websocket.maxSessions" and - "websocket.maxMessageSize" parameters. + answers pings, pings idle sessions, and supports the "websocket.maxSessions", + "websocket.maxMessageSize", "websocket.maxFrameSize" and "websocket.compression" + parameters. Server and client support "permessage-deflate" compression (RFC 7692), + enabled by default. - Added the `NettyConnectionUpgrade` engine hook: a Restlet returning a "101 Switching Protocols" response with this response attribute takes over the HTTP/1.1 connection (`NettyUpgradeContext`), which the WebSocket extension relies on. diff --git a/org.restlet.ext.websocket/src/main/java/module-info.java b/org.restlet.ext.websocket/src/main/java/module-info.java index 0037928816..00aa4c4517 100644 --- a/org.restlet.ext.websocket/src/main/java/module-info.java +++ b/org.restlet.ext.websocket/src/main/java/module-info.java @@ -13,6 +13,7 @@ // Netty is an implementation detail of the extension: required, not re-exported. requires io.netty.buffer; requires io.netty.codec; + requires io.netty.codec.compression; requires io.netty.codec.http; requires io.netty.common; requires io.netty.handler; diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java index 62f42e75d8..ab3ccc1e63 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java @@ -34,6 +34,8 @@ private WebSocketClient(Builder builder) throws IOException { builder.connectTimeout.toMillis(), builder.idleTimeout.toMillis(), builder.maxMessageSize, + (builder.maxFrameSize > 0) ? builder.maxFrameSize : builder.maxMessageSize, + builder.compression, builder.hostnameVerification, Logger.getLogger(WebSocketClient.class.getName())); } @@ -101,6 +103,10 @@ public static final class Builder { private int maxMessageSize = 65536; + private int maxFrameSize; + + private boolean compression = true; + private boolean hostnameVerification = true; private Builder() {} @@ -153,6 +159,33 @@ public Builder maxMessageSize(int maxMessageSize) { return this; } + /** + * Sets the maximum size in bytes of a received frame. A message may be fragmented in + * several frames. Defaults to the maximum message size, and is capped by it. + * + * @param maxFrameSize The maximum frame size. + * @return This builder. + */ + public Builder maxFrameSize(int maxFrameSize) { + if (maxFrameSize <= 0) { + throw new IllegalArgumentException("maxFrameSize must be positive"); + } + this.maxFrameSize = maxFrameSize; + return this; + } + + /** + * Sets whether "permessage-deflate" compression (RFC 7692) is offered to the server. + * Defaults to true; it is used only if the server accepts it. + * + * @param compression True to offer compression. + * @return This builder. + */ + public Builder compression(boolean compression) { + this.compression = compression; + return this; + } + /** * Sets whether the server host name is verified for "wss" URIs. Defaults to true. * diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketSession.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketSession.java index 1be7e4196c..31d4f70a7f 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketSession.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketSession.java @@ -113,9 +113,11 @@ default void close() throws IOException { /** * Returns the status code of the close frame received from the peer, {@link #NO_STATUS} if it - * had none, or {@link #ABNORMAL_CLOSURE} if the connection closed without one. + * had none, or {@link #ABNORMAL_CLOSURE} if the connection closed without one. When this side + * fails the session because of a peer error (for example {@link #MESSAGE_TOO_BIG} or {@link + * #PROTOCOL_ERROR}), the status code it sent is returned. * - * @return The close status code, or -1 while no close frame was received. + * @return The close status code, or -1 while the session was not closed. */ int getCloseCode(); diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnectionUpgrade.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnectionUpgrade.java index fec6c3968a..79dc5ca634 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnectionUpgrade.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnectionUpgrade.java @@ -12,12 +12,15 @@ import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelPipeline; +import io.netty.handler.codec.compression.ZlibCodecFactory; import io.netty.handler.codec.http.DefaultFullHttpRequest; import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.DefaultHttpHeaders; import io.netty.handler.codec.http.FullHttpRequest; import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaderValues; +import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpRequest; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpVersion; @@ -25,6 +28,11 @@ import io.netty.handler.codec.http.websocketx.WebSocketFrameAggregator; import io.netty.handler.codec.http.websocketx.WebSocketServerHandshaker; import io.netty.handler.codec.http.websocketx.WebSocketServerHandshakerFactory; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketExtensionData; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketExtensionUtil; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketServerExtension; +import io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateServerExtensionHandshaker; +import java.util.Map; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.logging.Level; @@ -74,6 +82,9 @@ public void upgrade(NettyUpgradeContext context) throws Exception { } int maxMessageSize = getMaxMessageSize(parameters); + int maxFrameSize = getMaxFrameSize(parameters, maxMessageSize); + boolean compression = + Boolean.parseBoolean(parameters.getFirstValue("websocket.compression", "true")); HttpRequest request = context.getRequest(); FullHttpRequest full = new DefaultFullHttpRequest( @@ -87,8 +98,8 @@ public void upgrade(NettyUpgradeContext context) throws Exception { location, upgrade.getSubprotocol(), WebSocketDecoderConfig.newBuilder() - .maxFramePayloadLength(maxMessageSize) - .allowExtensions(false) + .maxFramePayloadLength(maxFrameSize) + .allowExtensions(compression) .build()); WebSocketServerHandshaker handshaker = factory.newHandshaker(full); if (handshaker == null) { @@ -107,10 +118,23 @@ public void upgrade(NettyUpgradeContext context) throws Exception { context.getLogger()); // Installed before the handshake so that no frame sent right after it is missed. ChannelPipeline pipeline = channel.pipeline(); + HttpHeaders responseHeaders = new DefaultHttpHeaders(); + if (compression) { + WebSocketServerExtension extension = negotiateCompression(request, maxMessageSize); + if (extension != null) { + responseHeaders.set( + HttpHeaderNames.SEC_WEBSOCKET_EXTENSIONS, + toHeaderValue(extension.newReponseData())); + // The frame codecs installed by the handshake pass through these positions. + pipeline.addLast("ws-inflater", extension.newExtensionDecoder()); + pipeline.addLast("ws-deflater", extension.newExtensionEncoder()); + } + } pipeline.addLast("ws-aggregator", new WebSocketFrameAggregator(maxMessageSize)); pipeline.addLast("ws-session", session.newHandler()); try { - ChannelFuture future = handshaker.handshake(channel, full); + ChannelFuture future = + handshaker.handshake(channel, full, responseHeaders, channel.newPromise()); if (!future.await(HANDSHAKE_TIMEOUT_MS, TimeUnit.MILLISECONDS) || !future.isSuccess()) { context.getLogger().log(Level.FINE, "WebSocket handshake failed", future.cause()); channel.close(); @@ -159,6 +183,48 @@ private static Semaphore getPermits(NettyUpgradeContext context, Series new Semaphore(max)); } + /** + * Accepts the first "permessage-deflate" offer of the request that can be honored. + * + * @param request The upgrade request. + * @param maxMessageSize The maximum size of an inflated message. + * @return The negotiated extension, or null. + */ + private static WebSocketServerExtension negotiateCompression( + HttpRequest request, int maxMessageSize) { + String offers = request.headers().getAsString(HttpHeaderNames.SEC_WEBSOCKET_EXTENSIONS); + if (offers == null) { + return null; + } + PerMessageDeflateServerExtensionHandshaker handshaker = + new PerMessageDeflateServerExtensionHandshaker( + 6, + ZlibCodecFactory.isSupportingWindowSizeAndMemLevel(), + PerMessageDeflateServerExtensionHandshaker.MAX_WINDOW_SIZE, + false, + false, + maxMessageSize); + for (WebSocketExtensionData offer : WebSocketExtensionUtil.extractExtensions(offers)) { + WebSocketServerExtension extension = handshaker.handshakeExtension(offer); + if (extension != null) { + return extension; + } + } + return null; + } + + /** Formats extension data as a "Sec-WebSocket-Extensions" header value. */ + static String toHeaderValue(WebSocketExtensionData data) { + StringBuilder result = new StringBuilder(data.name()); + for (Map.Entry parameter : data.parameters().entrySet()) { + result.append("; ").append(parameter.getKey()); + if (parameter.getValue() != null) { + result.append('=').append(parameter.getValue()); + } + } + return result.toString(); + } + private static int getMaxMessageSize(Series parameters) { int result = Integer.parseInt(parameters.getFirstValue("websocket.maxMessageSize", "65536")); @@ -168,6 +234,18 @@ private static int getMaxMessageSize(Series parameters) { return result; } + private static int getMaxFrameSize(Series parameters, int maxMessageSize) { + String value = parameters.getFirstValue("websocket.maxFrameSize"); + if (value == null) { + return maxMessageSize; + } + int result = Integer.parseInt(value); + if (result <= 0) { + throw new IllegalArgumentException("'websocket.maxFrameSize' must be positive"); + } + return Math.min(result, maxMessageSize); + } + private static void sendError(Channel channel, HttpResponseStatus status) { FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status); response.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0); diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnector.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnector.java index 6b3343d26d..d0ff99b43c 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnector.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnector.java @@ -21,6 +21,7 @@ import io.netty.channel.nio.NioIoHandler; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.compression.ZlibCodecFactory; import io.netty.handler.codec.http.DefaultHttpHeaders; import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpClientCodec; @@ -30,6 +31,9 @@ import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakerFactory; import io.netty.handler.codec.http.websocketx.WebSocketFrameAggregator; import io.netty.handler.codec.http.websocketx.WebSocketVersion; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketClientExtensionHandler; +import io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateClientExtensionHandshaker; +import io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateServerExtensionHandshaker; import io.netty.handler.ssl.ApplicationProtocolConfig; import io.netty.handler.ssl.ClientAuth; import io.netty.handler.ssl.IdentityCipherSuiteFilter; @@ -70,6 +74,10 @@ public final class NettyWebSocketConnector implements AutoCloseable { private final int maxMessageSize; + private final int maxFrameSize; + + private final boolean compression; + private final boolean hostnameVerification; private final Logger logger; @@ -82,6 +90,8 @@ public final class NettyWebSocketConnector implements AutoCloseable { * @param idleTimeoutMs The idle time after which the connection is pinged, then closed, 0 for * none. * @param maxMessageSize The maximum size in bytes of a received message. + * @param maxFrameSize The maximum size in bytes of a received frame. + * @param compression Whether "permessage-deflate" compression is offered to the server. * @param hostnameVerification Whether the server host name is verified for "wss" URIs. * @param logger The logger. * @throws IOException If the SSL context cannot be created. @@ -91,6 +101,8 @@ public NettyWebSocketConnector( long connectTimeoutMs, long idleTimeoutMs, int maxMessageSize, + int maxFrameSize, + boolean compression, boolean hostnameVerification, Logger logger) throws IOException { @@ -112,6 +124,8 @@ public NettyWebSocketConnector( this.connectTimeoutMs = connectTimeoutMs; this.idleTimeoutMs = idleTimeoutMs; this.maxMessageSize = maxMessageSize; + this.maxFrameSize = Math.min(maxFrameSize, maxMessageSize); + this.compression = compression; this.hostnameVerification = hostnameVerification; this.logger = logger; this.group = new MultiThreadIoEventLoopGroup(0, NioIoHandler.newFactory()); @@ -150,9 +164,9 @@ public WebSocketSession connect(URI uri, List subprotocols, Map ready = group.next().newPromise(); Bootstrap bootstrap = @@ -239,6 +253,20 @@ private void initPipeline( } p.addLast("http-codec", new HttpClientCodec()); p.addLast("http-aggregator", new HttpObjectAggregator(8192)); + if (compression) { + // Offers permessage-deflate, then installs the compression codecs if accepted. + // Inflated messages are capped by the message size limit. + p.addLast( + "ws-compression", + new WebSocketClientExtensionHandler( + new PerMessageDeflateClientExtensionHandshaker( + 6, + ZlibCodecFactory.isSupportingWindowSizeAndMemLevel(), + PerMessageDeflateServerExtensionHandshaker.MAX_WINDOW_SIZE, + false, + false, + maxMessageSize))); + } p.addLast("ws-handshake", new HandshakeHandler(handshaker, ready)); } diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketSession.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketSession.java index 6cff20d400..34724126ac 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketSession.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketSession.java @@ -17,6 +17,7 @@ import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.handler.codec.TooLongFrameException; +import io.netty.handler.codec.compression.DecompressionException; import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame; import io.netty.handler.codec.http.websocketx.CorruptedWebSocketFrameException; @@ -283,6 +284,10 @@ private void onClose(ChannelHandlerContext ctx, int status, String reason) { } private void failSession(ChannelHandlerContext ctx, int code) { + if (!closeReceived) { + closeCode = code; + closeReceived = true; + } if (closeSent.compareAndSet(false, true) && ctx.channel().isActive()) { ctx.writeAndFlush(new CloseWebSocketFrame(code, null)) .addListener(ChannelFutureListener.CLOSE); @@ -372,6 +377,10 @@ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { code = corrupted.closeStatus().code(); } else if (cause instanceof TooLongFrameException) { code = MESSAGE_TOO_BIG; + } else if (cause instanceof DecompressionException) { + // Raised by the permessage-deflate decoder when an inflated message exceeds the + // maximum message size. + code = MESSAGE_TOO_BIG; } else { logger.log(Level.FINE, "WebSocket session failure", cause); } diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/package-info.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/package-info.java index f51cece052..7fe3aa700a 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/package-info.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/package-info.java @@ -12,9 +12,17 @@ * org.restlet.ext.websocket.WebSocketClient} opens client sessions, and both use the blocking * {@link org.restlet.ext.websocket.WebSocketSession}. * - *

Server connector parameters read by this extension: "websocket.maxSessions" (maximum number of - * open sessions, extra upgrades receive a 503 response; 0, the default, for no limit) and - * "websocket.maxMessageSize" (maximum size in bytes of a received message, 65536 by default; larger - * messages close the session with status 1009). + *

Server connector parameters read by this extension: + * + *

*/ package org.restlet.ext.websocket; diff --git a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketCompressionTestCase.java b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketCompressionTestCase.java new file mode 100644 index 0000000000..0cb1b5304f --- /dev/null +++ b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketCompressionTestCase.java @@ -0,0 +1,208 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.ext.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Server; +import org.restlet.data.Protocol; +import org.restlet.engine.Engine; +import org.restlet.engine.connector.HttpServerHelper; + +/** Tests permessage-deflate compression and the frame size limit. */ +class WebSocketCompressionTestCase { + + private Server server; + + @BeforeEach + void setUp() { + Engine.clearThreadLocalVariables(); + Engine.register(false); + Engine.getInstance().getRegisteredServers().add(new HttpServerHelper(null)); + } + + @AfterEach + void tearDown() throws Exception { + if (server != null) { + server.stop(); + } + Engine.clearThreadLocalVariables(); + Engine.register(); + } + + private static final WebSocketHandler ECHO = + session -> { + WebSocketMessage message; + while ((message = session.receive()) != null) { + session.send(message); + } + }; + + private int start(Context context) throws Exception { + server = + new Server( + context, + Protocol.HTTP, + 0, + new WebSocketUpgrader() { + @Override + protected WebSocketHandler accept(Request request, Response response) { + return ECHO; + } + }); + server.start(); + return server.getActualPort(); + } + + /** Sends a raw handshake and returns the response head lines. */ + private static List handshake(int port, String extensions) throws Exception { + try (Socket socket = new Socket("localhost", port)) { + String key = Base64.getEncoder().encodeToString(new byte[16]); + OutputStream out = socket.getOutputStream(); + out.write( + ("GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\n" + + "Connection: Upgrade\r\nSec-WebSocket-Version: 13\r\n" + + "Sec-WebSocket-Key: " + + key + + "\r\n" + + (extensions == null + ? "" + : "Sec-WebSocket-Extensions: " + extensions + "\r\n") + + "\r\n") + .getBytes(StandardCharsets.US_ASCII)); + out.flush(); + BufferedReader in = + new BufferedReader( + new InputStreamReader( + socket.getInputStream(), StandardCharsets.US_ASCII)); + List lines = new ArrayList<>(); + String line; + while ((line = in.readLine()) != null && !line.isEmpty()) { + lines.add(line.toLowerCase()); + } + return lines; + } + } + + @Test + void serverAcceptsPermessageDeflate() throws Exception { + int port = start(new Context()); + + List head = handshake(port, "permessage-deflate; client_max_window_bits"); + + assertTrue(head.get(0).startsWith("http/1.1 101"), head.toString()); + assertTrue( + head.stream() + .anyMatch( + h -> + h.startsWith("sec-websocket-extensions:") + && h.contains("permessage-deflate")), + head.toString()); + } + + @Test + void serverIgnoresCompressionWhenDisabledOrNotOffered() throws Exception { + Context context = new Context(); + context.getParameters().add("websocket.compression", "false"); + int port = start(context); + + List disabled = handshake(port, "permessage-deflate"); + assertTrue(disabled.get(0).startsWith("http/1.1 101"), disabled.toString()); + assertTrue( + disabled.stream().noneMatch(h -> h.startsWith("sec-websocket-extensions:")), + disabled.toString()); + + List notOffered = handshake(port, null); + assertTrue( + notOffered.stream().noneMatch(h -> h.startsWith("sec-websocket-extensions:")), + notOffered.toString()); + } + + @Test + void compressedMessagesRoundTrip() throws Exception { + int port = start(new Context()); + String large = "compressible ".repeat(10_000); + + try (WebSocketClient client = WebSocketClient.builder().maxMessageSize(1 << 20).build(); + WebSocketSession session = client.connect("ws://localhost:" + port + "/")) { + // 130 KB of text: larger than the server default limit (64 KB) once inflated, so the + // server would close the session if the limit were applied to compressed frames + // only. Use a smaller payload against the default limit instead. + String text = large.substring(0, 60_000); + session.sendText(text); + assertEquals(text, session.receive().getText()); + + byte[] binary = text.getBytes(StandardCharsets.UTF_8); + session.sendBinary(binary); + assertEquals(text, session.receive().getText()); + } + } + + @Test + void inflatedMessagesAreCappedByTheMessageSize() throws Exception { + Context context = new Context(); + context.getParameters().add("websocket.maxMessageSize", "1024"); + int port = start(context); + + try (WebSocketClient client = WebSocketClient.create()) { + WebSocketSession session = client.connect("ws://localhost:" + port + "/"); + // Compresses to far less than 1 KB on the wire, but inflates to 100 KB. + session.sendText("a".repeat(100_000)); + assertNull(session.receive()); + assertEquals(WebSocketSession.MESSAGE_TOO_BIG, session.getCloseCode()); + } + } + + @Test + void framesLargerThanTheFrameSizeCloseTheSession() throws Exception { + Context context = new Context(); + context.getParameters().add("websocket.compression", "false"); + context.getParameters().add("websocket.maxFrameSize", "16"); + int port = start(context); + + try (WebSocketClient client = WebSocketClient.builder().compression(false).build()) { + WebSocketSession session = client.connect("ws://localhost:" + port + "/"); + session.sendText("small"); + assertEquals("small", session.receive().getText()); + + session.sendText("x".repeat(64)); + assertNull(session.receive()); + assertEquals(WebSocketSession.MESSAGE_TOO_BIG, session.getCloseCode()); + } + } + + @Test + void clientFrameSizeLimitAppliesToServerMessages() throws Exception { + int port = start(new Context()); + + try (WebSocketClient client = + WebSocketClient.builder().compression(false).maxFrameSize(16).build()) { + WebSocketSession session = client.connect("ws://localhost:" + port + "/"); + session.sendText("y".repeat(64)); + assertNull(session.receive()); + assertEquals(WebSocketSession.MESSAGE_TOO_BIG, session.getCloseCode()); + } + } +} From 6b6e4d13a48eafc7e0a42239837addecd3a887ad Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:33:20 -0400 Subject: [PATCH 6/7] fix: address SonarCloud findings on WebSocket extension Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ext/websocket/WebSocketMessage.java | 42 ++++++---- .../internal/NettyWebSocketConnector.java | 3 +- .../internal/NettyWebSocketSession.java | 80 ++++++++++--------- .../websocket/WebSocketSessionTestCase.java | 12 ++- .../ext/websocket/WebSocketTestCase.java | 2 +- .../websocket/WebSocketUpgradeTestCase.java | 14 ++-- .../NettyConnectionUpgradeTestCase.java | 16 ++-- 7 files changed, 90 insertions(+), 79 deletions(-) diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketMessage.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketMessage.java index 7b1b3d0530..92d687b11e 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketMessage.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketMessage.java @@ -15,13 +15,11 @@ /** An immutable, complete WebSocket data message: either text or binary. */ public final class WebSocketMessage { - private final String text; + /** Either a {@link String} or a {@code byte[]}, never null. */ + private final Object payload; - private final byte[] data; - - private WebSocketMessage(String text, byte[] data) { - this.text = text; - this.data = data; + private WebSocketMessage(Object payload) { + this.payload = payload; } /** @@ -31,7 +29,7 @@ private WebSocketMessage(String text, byte[] data) { * @return The message. */ public static WebSocketMessage text(String text) { - return new WebSocketMessage(Objects.requireNonNull(text, "text"), null); + return new WebSocketMessage(Objects.requireNonNull(text, "text")); } /** @@ -41,7 +39,7 @@ public static WebSocketMessage text(String text) { * @return The message. */ public static WebSocketMessage binary(byte[] data) { - return new WebSocketMessage(null, Objects.requireNonNull(data, "data").clone()); + return new WebSocketMessage(Objects.requireNonNull(data, "data").clone()); } /** @@ -50,7 +48,7 @@ public static WebSocketMessage binary(byte[] data) { * @return True for a text message. */ public boolean isText() { - return text != null; + return payload instanceof String; } /** @@ -59,7 +57,7 @@ public boolean isText() { * @return True for a binary message. */ public boolean isBinary() { - return data != null; + return payload instanceof byte[]; } /** @@ -68,7 +66,9 @@ public boolean isBinary() { * @return The text. */ public String getText() { - return isText() ? text : new String(data, StandardCharsets.UTF_8); + return (payload instanceof String text) + ? text + : new String((byte[]) payload, StandardCharsets.UTF_8); } /** @@ -77,23 +77,31 @@ public String getText() { * @return A copy of the bytes. */ public byte[] getData() { - return isBinary() ? data.clone() : text.getBytes(StandardCharsets.UTF_8); + return (payload instanceof byte[] data) + ? data.clone() + : ((String) payload).getBytes(StandardCharsets.UTF_8); } @Override public boolean equals(Object obj) { - return obj instanceof WebSocketMessage that - && Objects.equals(text, that.text) - && Arrays.equals(data, that.data); + if (!(obj instanceof WebSocketMessage that)) { + return false; + } + if (payload instanceof byte[] data) { + return that.payload instanceof byte[] other && Arrays.equals(data, other); + } + return payload.equals(that.payload); } @Override public int hashCode() { - return 31 * Objects.hashCode(text) + Arrays.hashCode(data); + return (payload instanceof byte[] data) ? Arrays.hashCode(data) : payload.hashCode(); } @Override public String toString() { - return isText() ? "text[" + text.length() + " chars]" : "binary[" + data.length + " bytes]"; + return (payload instanceof String text) + ? "text[" + text.length() + " chars]" + : "binary[" + ((byte[]) payload).length + " bytes]"; } } diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnector.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnector.java index d0ff99b43c..90a12de5f4 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnector.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketConnector.java @@ -155,7 +155,8 @@ public WebSocketSession connect(URI uri, List subprotocols, Map 0) ? uri.getPort() : (secure ? 443 : 80); + int defaultPort = secure ? 443 : 80; + int port = (uri.getPort() > 0) ? uri.getPort() : defaultPort; HttpHeaders extraHeaders = new DefaultHttpHeaders(); headers.forEach(extraHeaders::add); diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketSession.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketSession.java index 34724126ac..144a1634e0 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketSession.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/internal/NettyWebSocketSession.java @@ -151,7 +151,7 @@ public WebSocketMessage receive() throws IOException { throw new InterruptedIOException("Interrupted while receiving a WebSocket message"); } if (next == END) { - inbound.offer(END); + inbound.add(END); return null; } if (inbound.size() < MAX_QUEUED_MESSAGES && readPaused.compareAndSet(true, false)) { @@ -224,15 +224,6 @@ private void awaitWritable() throws IOException { } } - private void signalWritability() { - writabilityLock.lock(); - try { - writable.signalAll(); - } finally { - writabilityLock.unlock(); - } - } - @Override public void close(int code, String reason) throws IOException { if (closeSent.compareAndSet(false, true) && channel.isActive()) { @@ -264,40 +255,51 @@ public String getCloseReason() { return closeReason; } - private void enqueue(WebSocketMessage message) { - inbound.offer(message); - } + /** Converts frames into queued messages and handles the control frames. */ + private final class Handler extends ChannelInboundHandlerAdapter { - private void onClose(ChannelHandlerContext ctx, int status, String reason) { - closeCode = (status < 0) ? NO_STATUS : status; - closeReason = (reason == null || reason.isEmpty()) ? null : reason; - closeReceived = true; - if (closeSent.compareAndSet(false, true)) { - int echo = - WebSocketCloseStatus.isValidStatusCode(closeCode) ? closeCode : NORMAL_CLOSURE; - ctx.writeAndFlush(new CloseWebSocketFrame(echo, null)) - .addListener(ChannelFutureListener.CLOSE); - } else { - ctx.close(); + private void signalWritability() { + writabilityLock.lock(); + try { + writable.signalAll(); + } finally { + writabilityLock.unlock(); + } } - inbound.offer(END); - } - private void failSession(ChannelHandlerContext ctx, int code) { - if (!closeReceived) { - closeCode = code; - closeReceived = true; + private void enqueue(WebSocketMessage message) { + inbound.add(message); } - if (closeSent.compareAndSet(false, true) && ctx.channel().isActive()) { - ctx.writeAndFlush(new CloseWebSocketFrame(code, null)) - .addListener(ChannelFutureListener.CLOSE); - } else { - ctx.close(); + + private void onClose(ChannelHandlerContext ctx, int status, String reason) { + closeCode = (status < 0) ? NO_STATUS : status; + closeReason = (reason == null || reason.isEmpty()) ? null : reason; + closeReceived = true; + if (closeSent.compareAndSet(false, true)) { + int echo = + WebSocketCloseStatus.isValidStatusCode(closeCode) + ? closeCode + : NORMAL_CLOSURE; + ctx.writeAndFlush(new CloseWebSocketFrame(echo, null)) + .addListener(ChannelFutureListener.CLOSE); + } else { + ctx.close(); + } + inbound.add(END); } - } - /** Converts frames into queued messages and handles the control frames. */ - private final class Handler extends ChannelInboundHandlerAdapter { + private void failSession(ChannelHandlerContext ctx, int code) { + if (!closeReceived) { + closeCode = code; + closeReceived = true; + } + if (closeSent.compareAndSet(false, true) && ctx.channel().isActive()) { + ctx.writeAndFlush(new CloseWebSocketFrame(code, null)) + .addListener(ChannelFutureListener.CLOSE); + } else { + ctx.close(); + } + } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { @@ -352,7 +354,7 @@ public void channelInactive(ChannelHandlerContext ctx) throws Exception { closeCode = ABNORMAL_CLOSURE; closeReceived = true; } - inbound.offer(END); + inbound.add(END); super.channelInactive(ctx); } diff --git a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketSessionTestCase.java b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketSessionTestCase.java index aefed28f32..482afa241e 100644 --- a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketSessionTestCase.java +++ b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketSessionTestCase.java @@ -168,12 +168,10 @@ void connectionFailuresAreReported() throws Exception { } try (WebSocketClient client = WebSocketClient.builder().connectTimeout(Duration.ofSeconds(2)).build()) { - assertThrows( - ConnectException.class, - () -> client.connect("ws://localhost:" + freePort + "/")); - assertThrows( - IllegalArgumentException.class, - () -> client.connect("http://localhost:" + freePort + "/")); + String refusedUri = "ws://localhost:" + freePort + "/"; + String httpUri = "http://localhost:" + freePort + "/"; + assertThrows(ConnectException.class, () -> client.connect(refusedUri)); + assertThrows(IllegalArgumentException.class, () -> client.connect(httpUri)); assertThrows(IllegalArgumentException.class, () -> client.connect("ws:///nohost")); } } @@ -194,7 +192,7 @@ void messagesHaveValueSemantics() { assertEquals(WebSocketMessage.text("a"), WebSocketMessage.text("a")); assertEquals(WebSocketMessage.text("a").hashCode(), WebSocketMessage.text("a").hashCode()); assertNotEquals(WebSocketMessage.text("a"), WebSocketMessage.binary(new byte[] {'a'})); - assertNotEquals(WebSocketMessage.text("a"), "a"); + assertNotEquals(new Object(), WebSocketMessage.text("a")); assertEquals("text[1 chars]", WebSocketMessage.text("a").toString()); } diff --git a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java index f60324cd1b..7a33d55ce2 100644 --- a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java +++ b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java @@ -268,7 +268,7 @@ void enforcesTheOpenSessionLimit() throws Exception { for (int i = 0; i < 50 && second == null; i++) { try { second = client.connect(base + "/"); - } catch (IOException e) { + } catch (IOException _) { Thread.sleep(50); } } diff --git a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketUpgradeTestCase.java b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketUpgradeTestCase.java index 2fb2acb8d4..c1966382a9 100644 --- a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketUpgradeTestCase.java +++ b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketUpgradeTestCase.java @@ -69,18 +69,14 @@ void acceptRecordsTheUpgradeOnTheResponse() { @Test void acceptRejectsInvalidUpgrades() { + Response plain = new Response(new Request(Method.GET, "http://localhost/")); + WebSocketHandler handler = session -> {}; assertThrows( - IllegalStateException.class, - () -> - WebSocketUpgrade.accept( - new Response(new Request(Method.GET, "http://localhost/")), - session -> {}, - null)); + IllegalStateException.class, () -> WebSocketUpgrade.accept(plain, handler, null)); + Response chat = new Response(upgradeRequest("chat")); assertThrows( IllegalArgumentException.class, - () -> - WebSocketUpgrade.accept( - new Response(upgradeRequest("chat")), session -> {}, "other")); + () -> WebSocketUpgrade.accept(chat, handler, "other")); assertNull(WebSocketUpgrade.get(new Response(upgradeRequest()))); } diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/NettyConnectionUpgradeTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/NettyConnectionUpgradeTestCase.java index 4e21a021ab..fd6cf3edd4 100644 --- a/org.restlet/src/test/java/org/restlet/engine/connector/NettyConnectionUpgradeTestCase.java +++ b/org.restlet/src/test/java/org/restlet/engine/connector/NettyConnectionUpgradeTestCase.java @@ -10,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import io.netty.buffer.Unpooled; @@ -84,10 +85,15 @@ public void handle(Request request, Response response) { private static String exchange(int port) throws Exception { try (Socket socket = new Socket("localhost", port)) { OutputStream out = socket.getOutputStream(); - out.write( - ("GET /up HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\n" - + "Upgrade: test\r\n\r\n") - .getBytes(StandardCharsets.US_ASCII)); + String request = + """ + GET /up HTTP/1.1\r + Host: localhost\r + Connection: Upgrade\r + Upgrade: test\r + \r + """; + out.write(request.getBytes(StandardCharsets.US_ASCII)); out.flush(); BufferedReader in = new BufferedReader( @@ -130,7 +136,7 @@ void handsTheConnectionOverToTheUpgrade() throws Exception { assertEquals(30000, context.getIdleTimeout()); assertTrue(context.getParameters().isEmpty()); context.getAttributes().put("seen", true); - assertTrue(context.getLogger() != null); + assertNotNull(context.getLogger()); } @Test From 455daff0ee3f68c7ca8792173e26801c463bd0ac Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:55:46 -0400 Subject: [PATCH 7/7] fix: don't read the next HTTP/1.1 request before the response is complete NettyServerHandler requested a channel read right after dispatching each request. When the decoder had already produced the whole request, that read could take the next pipelined request on the same connection while the current response was still being written, interleaving responses and corrupting the stream (flaky AsyncTestCase: 'Line Feed must be preceded by Carriage Return'). On HTTP/1.1 the read is now deferred to channelReadComplete and skipped once the request content has arrived. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../engine/netty/NettyServerHandler.java | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHandler.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHandler.java index 3cec34a87a..6ca8775f02 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHandler.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHandler.java @@ -55,6 +55,9 @@ class NettyServerHandler extends ChannelInboundHandlerAdapter { /** The call receiving the current request body. */ private NettyServerCall currentCall; + /** Whether to read the rest of the current HTTP/1.1 request once the channel read completes. */ + private boolean readRequestBody; + NettyServerHandler(NettyServerHelper helper, boolean confidential, StreamKind kind) { this.helper = helper; this.confidential = confidential; @@ -136,11 +139,29 @@ private void onRequest(ChannelHandlerContext ctx, HttpRequest request) { sendError(ctx, HttpResponseStatus.SERVICE_UNAVAILABLE); } - // Keep reading the body of this request (if any) without waiting for the call. - ctx.read(); + // Keep reading the body of this request (if any) without waiting for the call. On HTTP/1.1, + // the read is deferred to channelReadComplete: the decoder may already have produced the + // whole request, and reading now could take the next request before this response is + // complete. + if (kind.isStream()) { + ctx.read(); + } else { + readRequestBody = true; + } + } + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { + if (readRequestBody) { + readRequestBody = false; + ctx.read(); + } + super.channelReadComplete(ctx); } private void onContent(ChannelHandlerContext ctx, HttpContent content) { + // Request content drives the next reads from now on. + readRequestBody = false; ChannelInputStream body = currentBody; if (body == null) { content.release();