From 885c89d6f461f2d2dd315f0760d269a8de581b6b Mon Sep 17 00:00:00 2001
From: Arturo Bernal
Date: Wed, 10 Sep 2025 21:09:02 +0200
Subject: [PATCH 1/2] HTTPCLIENT-973: Introduce httpclient5-websocket H1
upgrade client
---
httpclient5-testing/pom.xml | 15 +
.../websocket/WebSocketClientTest.java | 530 ++++++++++++++++
.../performance/JettyEchoServer.java | 100 +++
.../websocket/performance/WsPerfRunner.java | 274 +++++++++
.../websocket/performance/WsPerfRunnerIT.java | 93 +++
httpclient5-websocket/pom.xml | 119 ++++
.../client5/http/websocket/api/WebSocket.java | 135 ++++
.../websocket/api/WebSocketClientConfig.java | 577 ++++++++++++++++++
.../http/websocket/api/WebSocketListener.java | 99 +++
.../http/websocket/api/package-info.java | 36 ++
.../client/CloseableWebSocketClient.java | 120 ++++
.../websocket/client/WebSocketClient.java | 74 +++
.../client/WebSocketClientBuilder.java | 447 ++++++++++++++
.../websocket/client/WebSocketClients.java | 78 +++
.../client/impl/AbstractWebSocketClient.java | 107 ++++
.../client/impl/DefaultWebSocketClient.java | 51 ++
.../impl/InternalWebSocketClientBase.java | 104 ++++
.../connector/WebSocketEndpointConnector.java | 215 +++++++
.../client/impl/connector/package-info.java | 36 ++
.../logging/WsLoggingExceptionCallback.java | 53 ++
.../client/impl/logging/package-info.java | 36 ++
.../websocket/client/impl/package-info.java | 36 ++
.../impl/protocol/Http1UpgradeProtocol.java | 500 +++++++++++++++
.../Http2ExtendedConnectProtocol.java | 64 ++
.../protocol/WebSocketProtocolStrategy.java | 59 ++
.../client/impl/protocol/package-info.java | 36 ++
.../http/websocket/client/package-info.java | 36 ++
.../WebSocketProtocolException.java | 41 ++
.../core/exceptions/package-info.java | 36 ++
.../core/extension/ExtensionChain.java | 135 ++++
.../core/extension/PerMessageDeflate.java | 195 ++++++
.../extension/WebSocketExtensionChain.java | 80 +++
.../core/extension/package-info.java | 36 ++
.../websocket/core/frame/FrameHeaderBits.java | 49 ++
.../websocket/core/frame/FrameOpcode.java | 88 +++
.../core/frame/WebSocketFrameWriter.java | 189 ++++++
.../websocket/core/frame/package-info.java | 36 ++
.../websocket/core/message/CloseCodec.java | 190 ++++++
.../websocket/core/message/package-info.java | 36 ++
.../http/websocket/core/package-info.java | 36 ++
.../websocket/core/util/ByteBufferPool.java | 127 ++++
.../websocket/core/util/package-info.java | 36 ++
.../client5/http/websocket/package-info.java | 74 +++
.../transport/WebSocketFrameDecoder.java | 172 ++++++
.../websocket/transport/WebSocketInbound.java | 449 ++++++++++++++
.../transport/WebSocketIoHandler.java | 121 ++++
.../transport/WebSocketOutbound.java | 577 ++++++++++++++++++
.../transport/WebSocketSessionState.java | 120 ++++
.../transport/WebSocketUpgrader.java | 114 ++++
.../websocket/transport/package-info.java | 37 ++
.../api/WebSocketClientConfigTest.java | 57 ++
.../websocket/client/WebSocketClientTest.java | 344 +++++++++++
.../Http1UpgradeProtocolExtensionTest.java | 106 ++++
.../core/extension/ExtensionChainTest.java | 50 ++
.../core/extension/MessageDeflateTest.java | 90 +++
.../websocket/core/frame/FrameReaderTest.java | 184 ++++++
.../websocket/core/frame/FrameWriterTest.java | 188 ++++++
.../core/message/CloseCodecTest.java | 87 +++
.../example/WebSocketEchoClient.java | 126 ++++
.../example/WebSocketEchoServer.java | 118 ++++
.../websocket/transport/WsDecoderTest.java | 149 +++++
.../transport/WsOutboundCompressionTest.java | 184 ++++++
.../src/test/resources/log4j2.xml | 36 ++
pom.xml | 27 +
64 files changed, 8750 insertions(+)
create mode 100644 httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/WebSocketClientTest.java
create mode 100644 httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/JettyEchoServer.java
create mode 100644 httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfRunner.java
create mode 100644 httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfRunnerIT.java
create mode 100644 httpclient5-websocket/pom.xml
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/package-info.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/AbstractWebSocketClient.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/DefaultWebSocketClient.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/InternalWebSocketClientBase.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnector.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/package-info.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/WsLoggingExceptionCallback.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/package-info.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/package-info.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocol.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/WebSocketProtocolStrategy.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/package-info.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/package-info.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/WebSocketProtocolException.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/package-info.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChain.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/PerMessageDeflate.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/WebSocketExtensionChain.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/package-info.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameHeaderBits.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameOpcode.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/WebSocketFrameWriter.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/package-info.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/CloseCodec.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/package-info.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/package-info.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/ByteBufferPool.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/package-info.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/package-info.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketFrameDecoder.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandler.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgrader.java
create mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/package-info.java
create mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfigTest.java
create mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/WebSocketClientTest.java
create mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocolExtensionTest.java
create mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChainTest.java
create mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/MessageDeflateTest.java
create mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameReaderTest.java
create mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameWriterTest.java
create mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/message/CloseCodecTest.java
create mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java
create mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java
create mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java
create mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundCompressionTest.java
create mode 100644 httpclient5-websocket/src/test/resources/log4j2.xml
diff --git a/httpclient5-testing/pom.xml b/httpclient5-testing/pom.xml
index 84e1ef5bba..dbb066c27c 100644
--- a/httpclient5-testing/pom.xml
+++ b/httpclient5-testing/pom.xml
@@ -77,6 +77,11 @@
httpclient5-fluent
test
+
+ org.apache.httpcomponents.client5
+ httpclient5-websocket
+ test
+
com.kohlschutter.junixsocket
junixsocket-core
@@ -113,6 +118,16 @@
junit-jupiter
test
+
+ org.eclipse.jetty
+ jetty-servlet
+ test
+
+
+ org.eclipse.jetty.websocket
+ websocket-server
+ test
+
diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/WebSocketClientTest.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/WebSocketClientTest.java
new file mode 100644
index 0000000000..8f485e2edf
--- /dev/null
+++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/WebSocketClientTest.java
@@ -0,0 +1,530 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.testing.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.time.Instant;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.hc.client5.http.websocket.api.WebSocket;
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient;
+import org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder;
+import org.apache.hc.client5.http.websocket.client.WebSocketClients;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.eclipse.jetty.websocket.api.Session;
+import org.eclipse.jetty.websocket.api.WebSocketAdapter;
+import org.eclipse.jetty.websocket.api.extensions.ExtensionConfig;
+import org.eclipse.jetty.websocket.servlet.WebSocketServlet;
+import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+final class WebSocketClientTest {
+
+ private Server server;
+ private int port;
+ private final AtomicBoolean pmceNegotiated = new AtomicBoolean(false);
+
+ @BeforeEach
+ void startServer() throws Exception {
+ server = new Server();
+ final ServerConnector connector = new ServerConnector(server);
+ connector.setPort(0); // auto-bind free port
+ server.addConnector(connector);
+
+ final ServletContextHandler ctx = new ServletContextHandler();
+ ctx.setContextPath("/");
+ ctx.addServlet(new ServletHolder(new EchoServlet()), "/echo");
+ ctx.addServlet(new ServletHolder(new PmceServlet(pmceNegotiated)), "/pmce");
+ ctx.addServlet(new ServletHolder(new InterleaveServlet()), "/interleave");
+ ctx.addServlet(new ServletHolder(new AbruptServlet()), "/abrupt");
+ ctx.addServlet(new ServletHolder(new TooBigServlet()), "/too-big");
+ server.setHandler(ctx);
+
+ server.start();
+ port = connector.getLocalPort();
+ }
+
+ @AfterEach
+ void stopServer() throws Exception {
+ if (server != null) {
+ server.stop();
+ }
+ }
+
+ private static URI uri(final int port, final String path) {
+ return URI.create("ws://localhost:" + port + path);
+ }
+
+ private static CloseableWebSocketClient newClient() {
+ final CloseableWebSocketClient client = WebSocketClientBuilder.create().build();
+ client.start(); // start reactor threads
+ return client;
+ }
+
+ @Test
+ void echo_uncompressed() throws Exception {
+ final URI uri = uri(port, "/echo");
+
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .enablePerMessageDeflate(false)
+ .build();
+
+ try (final CloseableWebSocketClient client = WebSocketClients.createDefault()) {
+ client.start();
+
+ final CountDownLatch done = new CountDownLatch(1);
+ final AtomicReference errorRef = new AtomicReference<>();
+ final StringBuilder echoed = new StringBuilder();
+ final AtomicReference wsRef = new AtomicReference<>();
+
+ System.out.println("[TEST] connecting: " + uri);
+
+ final WebSocketListener listener = new WebSocketListener() {
+
+ @Override
+ public void onOpen(final WebSocket ws) {
+ wsRef.set(ws);
+ final String payload = buildPayload();
+ System.out.println("[TEST] open: " + uri);
+ final boolean sent = ws.sendText(payload, true);
+ System.out.println("[TEST] sent (chars=" + payload.length() + ") sent=" + sent);
+ }
+
+ @Override
+ public void onText(final CharBuffer text, final boolean last) {
+ echoed.append(text);
+ if (last) {
+ System.out.println("[TEST] text (chars=" + text.length() + "): " +
+ (text.length() > 80 ? text.subSequence(0, 80) + "…" : text));
+ final WebSocket ws = wsRef.get();
+ if (ws != null) {
+ ws.close(1000, "done");
+ }
+ }
+ }
+
+ @Override
+ public void onClose(final int code, final String reason) {
+ try {
+ System.out.println("[TEST] close: " + code + " " + reason);
+ assertEquals(1000, code);
+ assertTrue(echoed.length() > 0, "No text echoed back");
+ } finally {
+ done.countDown();
+ }
+ }
+
+ @Override
+ public void onError(final Throwable ex) {
+ ex.printStackTrace(System.out);
+ errorRef.set(ex);
+ done.countDown();
+ }
+
+ private String buildPayload() {
+ final String base = "hello from hc5 WS @ " + Instant.now() + " — ";
+ final StringBuilder buf = new StringBuilder();
+ for (int i = 0; i < 256; i++) {
+ buf.append(base);
+ }
+ return buf.toString();
+ }
+
+ };
+
+ final CompletableFuture future = client.connect(uri, listener, cfg, null);
+ future.whenComplete((ws, ex) -> {
+ if (ex != null) {
+ errorRef.set(ex);
+ done.countDown();
+ }
+ });
+
+ assertTrue(done.await(10, TimeUnit.SECONDS), "WebSocket did not close in time");
+
+ final Throwable error = errorRef.get();
+ if (error != null) {
+ Assertions.fail("WebSocket error: " + error.getMessage(), error);
+ }
+ }
+ }
+
+ @Test
+ void echo_compressed_pmce() throws Exception {
+ final URI uri = uri(port, "/pmce");
+
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .enablePerMessageDeflate(true)
+ .offerServerNoContextTakeover(true)
+ .offerClientNoContextTakeover(true)
+ .offerClientMaxWindowBits(15)
+ .build();
+
+ try (final CloseableWebSocketClient client = WebSocketClients.createDefault()) {
+ client.start();
+
+ final CountDownLatch done = new CountDownLatch(1);
+ final AtomicReference errorRef = new AtomicReference<>();
+ final StringBuilder echoed = new StringBuilder();
+ final AtomicReference wsRef = new AtomicReference<>();
+
+ final WebSocketListener listener = new WebSocketListener() {
+
+ @Override
+ public void onOpen(final WebSocket ws) {
+ wsRef.set(ws);
+ final String payload = "pmce test " + Instant.now();
+ ws.sendText(payload, true);
+ }
+
+ @Override
+ public void onText(final CharBuffer text, final boolean last) {
+ echoed.append(text);
+ if (last) {
+ final WebSocket ws = wsRef.get();
+ if (ws != null) {
+ ws.close(1000, "done");
+ }
+ }
+ }
+
+ @Override
+ public void onClose(final int code, final String reason) {
+ try {
+ assertEquals(1000, code);
+ assertTrue(pmceNegotiated.get(), "PMCE not negotiated on server");
+ assertTrue(echoed.length() > 0, "No text echoed back");
+ } finally {
+ done.countDown();
+ }
+ }
+
+ @Override
+ public void onError(final Throwable ex) {
+ errorRef.set(ex);
+ done.countDown();
+ }
+ };
+
+ client.connect(uri, listener, cfg, null);
+ assertTrue(done.await(10, TimeUnit.SECONDS), "WebSocket did not close in time");
+
+ final Throwable error = errorRef.get();
+ if (error != null) {
+ Assertions.fail("WebSocket error: " + error.getMessage(), error);
+ }
+ }
+ }
+
+ @Test
+ void ping_interleaved_fragmentation() throws Exception {
+ final CountDownLatch gotText = new CountDownLatch(1);
+ final CountDownLatch gotPong = new CountDownLatch(1);
+
+ try (final CloseableWebSocketClient client = newClient()) {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .enablePerMessageDeflate(false)
+ .build();
+
+ final URI u = uri(port, "/interleave");
+ client.connect(u, new WebSocketListener() {
+ @Override
+ public void onOpen(final WebSocket ws) {
+ ws.ping(null);
+ final String prefix = "hello from hc5 WS @ " + Instant.now() + " — ";
+ final StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < 256; i++) {
+ sb.append(prefix);
+ }
+ ws.sendText(sb.toString(), true);
+ }
+
+ @Override
+ public void onText(final CharBuffer text, final boolean last) {
+ gotText.countDown();
+ }
+
+ @Override
+ public void onPong(final ByteBuffer payload) {
+ gotPong.countDown();
+ }
+
+ @Override
+ public void onClose(final int code, final String reason) {
+ // the servlet closes after echo
+ }
+
+ @Override
+ public void onError(final Throwable ex) {
+ gotText.countDown();
+ gotPong.countDown();
+ }
+ }, cfg, null);
+
+ assertTrue(gotPong.await(10, TimeUnit.SECONDS), "did not receive PONG");
+ assertTrue(gotText.await(10, TimeUnit.SECONDS), "did not receive TEXT");
+ }
+ }
+
+ @Test
+ void max_message_1009() throws Exception {
+ final CountDownLatch done = new CountDownLatch(1);
+ final AtomicReference codeRef = new AtomicReference<>();
+ final AtomicReference errorRef = new AtomicReference<>();
+ final int maxMessage = 2048; // 2 KiB
+
+ try (final CloseableWebSocketClient client = newClient()) {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .setMaxMessageSize(maxMessage)
+ .enablePerMessageDeflate(false)
+ .build();
+
+ final URI u = uri(port, "/too-big");
+ client.connect(u, new WebSocketListener() {
+ @Override
+ public void onOpen(final WebSocket ws) {
+ // Trigger the server to send an oversized text message.
+ ws.sendText("trigger-too-big", true);
+ }
+
+ @Override
+ public void onText(final CharBuffer text, final boolean last) {
+ // We may or may not see some text before the 1009 close.
+ }
+
+ @Override
+ public void onClose(final int code, final String reason) {
+ codeRef.set(code);
+ done.countDown();
+ }
+
+ @Override
+ public void onError(final Throwable ex) {
+ errorRef.set(ex);
+ done.countDown();
+ }
+ }, cfg, null);
+
+ assertTrue(done.await(10, TimeUnit.SECONDS), "timeout waiting for 1009 close");
+
+ final Throwable error = errorRef.get();
+ if (error != null) {
+ Assertions.fail("WebSocket error: " + error.getMessage(), error);
+ }
+
+ assertEquals(Integer.valueOf(1009), codeRef.get(), "expected 1009 close code");
+ }
+ }
+
+ @Test
+ void abnormal_close_1006() throws Exception {
+ final CountDownLatch done = new CountDownLatch(1);
+
+ try (final CloseableWebSocketClient client = newClient()) {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build();
+
+ final URI u = uri(port, "/abrupt");
+ client.connect(u, new WebSocketListener() {
+ @Override
+ public void onOpen(final WebSocket ws) {
+ // do nothing; server will disconnect abruptly
+ }
+
+ @Override
+ public void onClose(final int code, final String reason) {
+ assertEquals(1006, code);
+ done.countDown();
+ }
+
+ @Override
+ public void onError(final Throwable ex) {
+ // acceptable; still expect onClose(1006)
+ }
+ }, cfg, null);
+
+ assertTrue(done.await(10, TimeUnit.SECONDS), "did not see 1006 abnormal closure");
+ }
+ }
+
+ public static final class EchoServlet extends WebSocketServlet {
+ @Override
+ public void configure(final WebSocketServletFactory factory) {
+ factory.getPolicy().setIdleTimeout(30000);
+ factory.setCreator((req, resp) -> new EchoSocket());
+ }
+ }
+
+ public static final class EchoSocket extends WebSocketAdapter {
+ @Override
+ public void onWebSocketText(final String msg) {
+ final Session s = getSession();
+ if (s != null && s.isOpen()) {
+ s.getRemote().sendString(msg, null);
+ s.close(1000, "done");
+ }
+ }
+ }
+
+ public static final class PmceServlet extends WebSocketServlet {
+ private final AtomicBoolean negotiated;
+
+ public PmceServlet(final AtomicBoolean negotiated) {
+ this.negotiated = negotiated;
+ }
+
+ @Override
+ public void configure(final WebSocketServletFactory factory) {
+ factory.getPolicy().setIdleTimeout(30000);
+ factory.setCreator((req, resp) -> new PmceSocket(negotiated));
+ }
+ }
+
+ public static final class PmceSocket extends WebSocketAdapter {
+ private final AtomicBoolean negotiated;
+
+ public PmceSocket(final AtomicBoolean negotiated) {
+ this.negotiated = negotiated;
+ }
+
+ @Override
+ public void onWebSocketConnect(final Session sess) {
+ super.onWebSocketConnect(sess);
+ if (sess != null) {
+ final java.util.List exts = sess.getUpgradeRequest().getExtensions();
+ boolean hasPmce = false;
+ if (exts != null) {
+ for (final ExtensionConfig ext : exts) {
+ if ("permessage-deflate".equalsIgnoreCase(ext.getName())) {
+ hasPmce = true;
+ break;
+ }
+ }
+ }
+ negotiated.set(hasPmce);
+ }
+ }
+
+ @Override
+ public void onWebSocketText(final String msg) {
+ final Session s = getSession();
+ if (s != null && s.isOpen()) {
+ s.getRemote().sendString(msg, null);
+ s.close(1000, "done");
+ }
+ }
+ }
+
+ public static final class InterleaveServlet extends WebSocketServlet {
+ @Override
+ public void configure(final WebSocketServletFactory factory) {
+ factory.getPolicy().setIdleTimeout(30000);
+ factory.setCreator((req, resp) -> new InterleaveSocket());
+ }
+ }
+
+ public static final class InterleaveSocket extends WebSocketAdapter {
+ @Override
+ public void onWebSocketText(final String msg) {
+ final Session s = getSession();
+ if (s == null) {
+ return;
+ }
+ try {
+ s.getRemote().sendPing(ByteBuffer.wrap(new byte[]{'p', 'i', 'n', 'g'}));
+ } catch (final IOException e) {
+ throw new RuntimeException(e);
+ }
+ s.getRemote().sendString(msg, null);
+ }
+ }
+
+ public static final class AbruptServlet extends WebSocketServlet {
+ @Override
+ public void configure(final WebSocketServletFactory factory) {
+ factory.getPolicy().setIdleTimeout(30000);
+ factory.setCreator((req, resp) -> new AbruptSocket());
+ }
+ }
+
+ public static final class AbruptSocket extends WebSocketAdapter {
+ @Override
+ public void onWebSocketConnect(final Session sess) {
+ super.onWebSocketConnect(sess);
+ // Immediately drop the TCP connection without sending a CLOSE frame.
+ try {
+ sess.disconnect();
+ } catch (final Throwable ignore) {
+ // ignore
+ }
+ }
+ }
+
+ public static final class TooBigServlet extends WebSocketServlet {
+ @Override
+ public void configure(final WebSocketServletFactory factory) {
+ factory.getPolicy().setIdleTimeout(30000);
+ factory.setCreator((req, resp) -> new TooBigSocket());
+ }
+ }
+
+ public static final class TooBigSocket extends WebSocketAdapter {
+ @Override
+ public void onWebSocketText(final String msg) {
+ final Session sess = getSession();
+ if (sess == null || !sess.isOpen()) {
+ return;
+ }
+ final StringBuilder sb = new StringBuilder();
+ final String chunk = "1234567890abcdef-";
+ // Build something comfortably larger than the maxMessage (2 KiB in the test)
+ while (sb.length() <= 8192) {
+ sb.append(chunk);
+ }
+ final String big = sb.toString();
+ sess.getRemote().sendString(big, null);
+ // No CLOSE here; the client must decide to close with 1009.
+ }
+ }
+}
diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/JettyEchoServer.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/JettyEchoServer.java
new file mode 100644
index 0000000000..09917551ac
--- /dev/null
+++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/JettyEchoServer.java
@@ -0,0 +1,100 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.testing.websocket.performance;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.websocket.api.Session;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
+import org.eclipse.jetty.websocket.api.annotations.WebSocket;
+import org.eclipse.jetty.websocket.servlet.WebSocketServlet;
+import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
+
+public final class JettyEchoServer {
+ private final Server server = new Server();
+ private int port;
+
+ public void start() throws Exception {
+ // Ephemeral port
+ final ServerConnector connector = new ServerConnector(server);
+ connector.setPort(0);
+ server.setConnectors(new Connector[]{connector});
+
+ // Context + WebSocket servlet at /echo
+ final ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS);
+ context.setContextPath("/");
+ context.addServlet(EchoServlet.class, "/echo");
+ server.setHandler(context);
+
+ server.start();
+ this.port = ((ServerConnector) server.getConnectors()[0]).getLocalPort();
+ }
+
+ public void stop() throws Exception {
+ server.stop();
+ server.destroy();
+ }
+
+ public String uri() {
+ return "ws://127.0.0.1:" + port + "/echo";
+ }
+
+ public static class EchoServlet extends WebSocketServlet {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void configure(final WebSocketServletFactory factory) {
+ // PMCE (permessage-deflate) is available by default in Jetty 9.4 when on classpath.
+ // No need to call the deprecated getExtensionFactory().
+ factory.getPolicy().setMaxTextMessageSize(65536);
+ factory.getPolicy().setMaxBinaryMessageSize(65536);
+ factory.register(EchoSocket.class);
+ }
+ }
+
+ @WebSocket
+ public static class EchoSocket {
+ @OnWebSocketMessage
+ public void onText(final Session session, final String message) {
+ try {
+ session.getRemote().sendString(message);
+ } catch (final IOException ignore) { }
+ }
+
+ @OnWebSocketMessage
+ public void onBinary(final Session session, final byte[] payload, final int offset, final int len) {
+ try {
+ session.getRemote().sendBytes(ByteBuffer.wrap(payload, offset, len));
+ } catch (final IOException ignore) { }
+ }
+ }
+}
diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfRunner.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfRunner.java
new file mode 100644
index 0000000000..035eb021b9
--- /dev/null
+++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfRunner.java
@@ -0,0 +1,274 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.testing.websocket.performance;
+
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.locks.LockSupport;
+
+import org.apache.hc.client5.http.websocket.api.WebSocket;
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient;
+import org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder;
+
+public final class WsPerfRunner {
+
+ public static void main(final String[] args) throws Exception {
+ final Args a = Args.parse(args);
+ System.out.printf(Locale.ROOT,
+ "mode=%s uri=%s clients=%d durationSec=%d bytes=%d inflight=%d pmce=%s compressible=%s%n",
+ a.mode, a.uri, a.clients, a.durationSec, a.bytes, a.inflight, a.pmce, a.compressible);
+
+ final ExecutorService pool = Executors.newFixedThreadPool(Math.min(a.clients, 64));
+ final AtomicLong sends = new AtomicLong();
+ final AtomicLong recvs = new AtomicLong();
+ final AtomicLong errors = new AtomicLong();
+ final ConcurrentLinkedQueue lats = new ConcurrentLinkedQueue<>();
+ final CountDownLatch ready = new CountDownLatch(a.clients);
+ final CountDownLatch done = new CountDownLatch(a.clients);
+
+ final byte[] payload = a.compressible ? makeCompressible(a.bytes) : makeRandom(a.bytes);
+ final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(a.durationSec);
+
+ for (int i = 0; i < a.clients; i++) {
+ final int id = i;
+ pool.submit(() -> runClient(id, a, payload, sends, recvs, errors, lats, ready, done, deadline));
+ }
+
+ ready.await(); // all connected
+ done.await(); // test finished
+ pool.shutdown();
+
+ final long totalRecv = recvs.get();
+ final long totalSend = sends.get();
+ final double secs = a.durationSec;
+ final double msgps = totalRecv / secs;
+ final double mbps = (totalRecv * (long) a.bytes) / (1024.0 * 1024.0) / secs;
+
+ System.out.printf(Locale.ROOT, "sent=%d recv=%d errors=%d%n", totalSend, totalRecv, errors.get());
+ System.out.printf(Locale.ROOT, "throughput: %.0f msg/s, %.2f MiB/s%n", msgps, mbps);
+
+ if (!lats.isEmpty()) {
+ final long[] arr = lats.stream().mapToLong(Long::longValue).toArray();
+ Arrays.sort(arr);
+ System.out.printf(Locale.ROOT,
+ "latency (ms): p50=%.3f p95=%.3f p99=%.3f max=%.3f samples=%d%n",
+ nsToMs(p(arr, 0.50)), nsToMs(p(arr, 0.95)), nsToMs(p(arr, 0.99)), nsToMs(arr[arr.length - 1]), arr.length);
+ }
+ }
+
+ private static void runClient(
+ final int id, final Args a, final byte[] payload,
+ final AtomicLong sends, final AtomicLong recvs, final AtomicLong errors,
+ final ConcurrentLinkedQueue lats,
+ final CountDownLatch ready, final CountDownLatch done, final long deadlineNanos) {
+
+ // Per-connection WebSocket config
+ final WebSocketClientConfig.Builder b = WebSocketClientConfig.custom()
+ .setConnectTimeout(org.apache.hc.core5.util.Timeout.ofSeconds(5))
+// .setExchangeTimeout(org.apache.hc.core5.util.Timeout.ofSeconds(5))
+ .setCloseWaitTimeout(org.apache.hc.core5.util.Timeout.ofSeconds(3))
+ .setOutgoingChunkSize(4096)
+ .setAutoPong(true);
+
+ if (a.pmce) {
+ b.enablePerMessageDeflate(true)
+ .offerClientNoContextTakeover(false)
+ .offerServerNoContextTakeover(false)
+ .offerClientMaxWindowBits(null)
+ .offerServerMaxWindowBits(null);
+ }
+ final WebSocketClientConfig cfg = b.build();
+
+ // Build a client instance (closeable) with our default per-connection config
+ try (final CloseableWebSocketClient client =
+ WebSocketClientBuilder.create()
+ .defaultConfig(cfg)
+ .build()) {
+
+ final AtomicInteger inflight = new AtomicInteger();
+ final AtomicBoolean open = new AtomicBoolean(false);
+
+ final CompletableFuture cf = client.connect(
+ URI.create(a.uri),
+ new WebSocketListener() {
+ @Override
+ public void onOpen(final WebSocket ws) {
+ open.set(true);
+ ready.countDown();
+ // Prime in-flight
+ for (int j = 0; j < a.inflight; j++) {
+ sendOne(ws, a, payload, sends, inflight);
+ }
+ }
+
+ @Override
+ public void onBinary(final ByteBuffer p, final boolean last) {
+ final long t1 = System.nanoTime();
+ if (a.mode == Mode.LATENCY) {
+ if (p.remaining() >= 8) {
+ final long t0 = p.getLong(p.position());
+ lats.add(t1 - t0);
+ }
+ }
+ recvs.incrementAndGet();
+ inflight.decrementAndGet();
+ }
+
+ @Override
+ public void onError(final Throwable ex) {
+ errors.incrementAndGet();
+ }
+
+ @Override
+ public void onClose(final int code, final String reason) {
+ open.set(false);
+ }
+ });
+
+ try {
+ final WebSocket ws = cf.get(15, TimeUnit.SECONDS);
+
+ // Main loop: keep target inflight until deadline
+ while (System.nanoTime() < deadlineNanos) {
+ while (open.get() && inflight.get() < a.inflight) {
+ sendOne(ws, a, payload, sends, inflight);
+ }
+ // backoff a bit
+ LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
+ }
+
+ // Drain a bit more
+ Thread.sleep(200);
+ ws.close(1000, "bye");
+ } catch (final Exception e) {
+ errors.incrementAndGet();
+ // If connect failed early, make sure the "all connected" gate doesn't block the whole run
+ ready.countDown();
+ } finally {
+ done.countDown();
+ }
+ } catch (final Exception ignore) {
+ }
+ }
+
+ private static void sendOne(final WebSocket ws, final Args a, final byte[] payload,
+ final AtomicLong sends, final AtomicInteger inflight) {
+ final ByteBuffer p = ByteBuffer.allocate(payload.length + 8);
+ final long t0 = System.nanoTime();
+ p.putLong(t0).put(payload).flip();
+ if (ws.sendBinary(p, true)) {
+ inflight.incrementAndGet();
+ sends.incrementAndGet();
+ }
+ }
+
+
+ private enum Mode { THROUGHPUT, LATENCY }
+
+ private static final class Args {
+ String uri = "ws://localhost:8080/echo";
+ int clients = 8;
+ int durationSec = 15;
+ int bytes = 512;
+ int inflight = 32;
+ boolean pmce = false;
+ boolean compressible = true;
+ Mode mode = Mode.THROUGHPUT;
+
+ static Args parse(final String[] a) {
+ final Args r = new Args();
+ for (final String s : a) {
+ final String[] kv = s.split("=", 2);
+ if (kv.length != 2) {
+ continue;
+ }
+ switch (kv[0]) {
+ case "uri":
+ r.uri = kv[1];
+ break;
+ case "clients":
+ r.clients = Integer.parseInt(kv[1]);
+ break;
+ case "durationSec":
+ r.durationSec = Integer.parseInt(kv[1]);
+ break;
+ case "bytes":
+ r.bytes = Integer.parseInt(kv[1]);
+ break;
+ case "inflight":
+ r.inflight = Integer.parseInt(kv[1]);
+ break;
+ case "pmce":
+ r.pmce = Boolean.parseBoolean(kv[1]);
+ break;
+ case "compressible":
+ r.compressible = Boolean.parseBoolean(kv[1]);
+ break;
+ case "mode":
+ r.mode = Mode.valueOf(kv[1]);
+ break;
+ }
+ }
+ return r;
+ }
+ }
+
+ private static byte[] makeCompressible(final int n) {
+ final byte[] b = new byte[n];
+ Arrays.fill(b, (byte) 'A');
+ return b;
+ }
+
+ private static byte[] makeRandom(final int n) {
+ final byte[] b = new byte[n];
+ ThreadLocalRandom.current().nextBytes(b);
+ return b;
+ }
+
+ private static double nsToMs(final long ns) {
+ return ns / 1_000_000.0;
+ }
+
+ private static long p(final long[] arr, final double q) {
+ final int i = (int) Math.min(arr.length - 1, Math.max(0, Math.round((arr.length - 1) * q)));
+ return arr[i];
+ }
+}
diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfRunnerIT.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfRunnerIT.java
new file mode 100644
index 0000000000..ad22147694
--- /dev/null
+++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfRunnerIT.java
@@ -0,0 +1,93 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.testing.websocket.performance;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+@Disabled("Performance runner; not part of the unit test suite")
+class WsPerfRunnerIT {
+
+ private static JettyEchoServer srv;
+
+ @BeforeAll
+ static void up() throws Exception {
+ srv = new JettyEchoServer();
+ srv.start();
+ }
+
+ @AfterAll
+ static void down() throws Exception {
+ srv.stop();
+ }
+
+ @Test
+ void throughput_sample() throws Exception {
+ WsPerfRunner.main(new String[]{
+ "mode=THROUGHPUT",
+ "uri=" + srv.uri(), // e.g., ws://127.0.0.1:PORT/
+ "clients=12",
+ "durationSec=10",
+ "bytes=512",
+ "inflight=32",
+ "pmce=true",
+ "compressible=true"
+ });
+ }
+
+
+ @Test
+ void latency_sample() throws Exception {
+ WsPerfRunner.main(new String[]{
+ "mode=LATENCY",
+ "uri=" + srv.uri(),
+ "clients=4",
+ "durationSec=10",
+ "bytes=64",
+ "inflight=4",
+ "pmce=false",
+ "compressible=false"
+ });
+ }
+
+ @Test
+ void throughput_non_compressible_sample() throws Exception {
+ WsPerfRunner.main(new String[]{
+ "mode=THROUGHPUT",
+ "uri=" + srv.uri(),
+ "clients=12",
+ "durationSec=10",
+ "bytes=512",
+ "inflight=32",
+ // PMCE negotiated, but payload is high-entropy (random-ish)
+ "pmce=true",
+ "compressible=false"
+ });
+ }
+}
diff --git a/httpclient5-websocket/pom.xml b/httpclient5-websocket/pom.xml
new file mode 100644
index 0000000000..bd904b0e9c
--- /dev/null
+++ b/httpclient5-websocket/pom.xml
@@ -0,0 +1,119 @@
+
+
+ 4.0.0
+
+ org.apache.httpcomponents.client5
+ httpclient5-parent
+ 5.7-alpha1-SNAPSHOT
+
+
+ httpclient5-websocket
+ Apache HttpClient WebSocket
+ WebSocket support for HttpClient
+ jar
+
+
+ org.apache.httpcomponents.client5.websocket
+
+
+
+
+ org.apache.httpcomponents.client5
+ httpclient5
+
+
+ org.apache.httpcomponents.client5
+ httpclient5-cache
+
+
+ org.slf4j
+ slf4j-api
+
+
+ org.apache.logging.log4j
+ log4j-slf4j-impl
+ true
+ test
+
+
+ org.apache.logging.log4j
+ log4j-core
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.apache.commons
+ commons-compress
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ ${junit.version}
+ test
+
+
+ org.eclipse.jetty
+ jetty-server
+ test
+
+
+ org.eclipse.jetty
+ jetty-servlet
+ test
+
+
+ org.eclipse.jetty.websocket
+ websocket-server
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+ false
+
+
+
+ com.github.siom79.japicmp
+ japicmp-maven-plugin
+
+ true
+
+
+
+
+
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java
new file mode 100644
index 0000000000..c235ffc9a0
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java
@@ -0,0 +1,135 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.api;
+
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Client-side representation of a single WebSocket connection.
+ *
+ * Instances of this interface are thread-safe. Outbound operations may be
+ * invoked from arbitrary application threads. Inbound events are delivered
+ * to the associated {@link WebSocketListener}.
+ *
+ * @since 5.7
+ */
+public interface WebSocket {
+
+ /**
+ * Returns {@code true} if the WebSocket is still open and not in the
+ * process of closing.
+ */
+ boolean isOpen();
+
+ /**
+ * Sends a PING control frame with the given payload.
+ * The payload size must not exceed 125 bytes.
+ *
+ * @param data optional payload buffer; may be {@code null}.
+ * @return {@code true} if the frame was accepted for sending,
+ * {@code false} if the connection is closing or closed.
+ */
+ boolean ping(ByteBuffer data);
+
+ /**
+ * Sends a PONG control frame with the given payload.
+ * The payload size must not exceed 125 bytes.
+ *
+ * @param data optional payload buffer; may be {@code null}.
+ * @return {@code true} if the frame was accepted for sending,
+ * {@code false} if the connection is closing or closed.
+ */
+ boolean pong(ByteBuffer data);
+
+ /**
+ * Sends a text message fragment.
+ *
+ * @param data text data to send. Must not be {@code null}.
+ * @param finalFragment {@code true} if this is the final fragment of
+ * the message, {@code false} if more fragments
+ * will follow.
+ * @return {@code true} if the fragment was accepted for sending,
+ * {@code false} if the connection is closing or closed.
+ */
+ boolean sendText(CharSequence data, boolean finalFragment);
+
+ /**
+ * Sends a binary message fragment.
+ *
+ * @param data binary data to send. Must not be {@code null}.
+ * @param finalFragment {@code true} if this is the final fragment of
+ * the message, {@code false} if more fragments
+ * will follow.
+ * @return {@code true} if the fragment was accepted for sending,
+ * {@code false} if the connection is closing or closed.
+ */
+ boolean sendBinary(ByteBuffer data, boolean finalFragment);
+
+ /**
+ * Sends a batch of text fragments as a single message.
+ *
+ * @param fragments ordered list of fragments; must not be {@code null}
+ * or empty.
+ * @param finalFragment {@code true} if this batch completes the logical
+ * message, {@code false} if subsequent batches
+ * will follow.
+ * @return {@code true} if the batch was accepted for sending,
+ * {@code false} if the connection is closing or closed.
+ */
+ boolean sendTextBatch(List fragments, boolean finalFragment);
+
+ /**
+ * Sends a batch of binary fragments as a single message.
+ *
+ * @param fragments ordered list of fragments; must not be {@code null}
+ * or empty.
+ * @param finalFragment {@code true} if this batch completes the logical
+ * message, {@code false} if subsequent batches
+ * will follow.
+ * @return {@code true} if the batch was accepted for sending,
+ * {@code false} if the connection is closing or closed.
+ */
+ boolean sendBinaryBatch(List fragments, boolean finalFragment);
+
+ /**
+ * Initiates the WebSocket close handshake.
+ *
+ * The returned future is completed once the close frame has been
+ * queued for sending. It does not wait for the peer's close
+ * frame or for the underlying TCP connection to be closed.
+ *
+ * @param statusCode close status code to send.
+ * @param reason optional close reason; may be {@code null}.
+ * @return a future that completes when the close frame has been
+ * enqueued, or completes exceptionally if the close
+ * could not be initiated.
+ */
+ CompletableFuture close(int statusCode, String reason);
+}
+
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java
new file mode 100644
index 0000000000..7875a5584c
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java
@@ -0,0 +1,577 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.api;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.Timeout;
+
+/**
+ * Immutable configuration for {@link WebSocket} clients.
+ *
+ * Instances are normally created via the associated builder. The
+ * configuration controls timeouts, maximum frame and message sizes,
+ * fragmentation behaviour, buffer pooling and optional automatic
+ * responses to PING frames.
+ *
+ * @since 5.7
+ */
+public final class WebSocketClientConfig {
+
+ private final Timeout connectTimeout;
+ private final List subprotocols;
+
+ // PMCE offer
+ private final boolean perMessageDeflateEnabled;
+ private final boolean offerServerNoContextTakeover;
+ private final boolean offerClientNoContextTakeover;
+ private final Integer offerClientMaxWindowBits;
+ private final Integer offerServerMaxWindowBits;
+
+ // Framing / flow
+ private final int maxFrameSize;
+ private final int outgoingChunkSize;
+ private final int maxFramesPerTick;
+
+ // Buffers / pool
+ private final int ioPoolCapacity;
+ private final boolean directBuffers;
+
+ // Behavior
+ private final boolean autoPong;
+ private final Timeout closeWaitTimeout;
+ private final long maxMessageSize;
+
+ // Outbound control queue
+ private final int maxOutboundControlQueue;
+
+ private WebSocketClientConfig(
+ final Timeout connectTimeout,
+ final List subprotocols,
+ final boolean perMessageDeflateEnabled,
+ final boolean offerServerNoContextTakeover,
+ final boolean offerClientNoContextTakeover,
+ final Integer offerClientMaxWindowBits,
+ final Integer offerServerMaxWindowBits,
+ final int maxFrameSize,
+ final int outgoingChunkSize,
+ final int maxFramesPerTick,
+ final int ioPoolCapacity,
+ final boolean directBuffers,
+ final boolean autoPong,
+ final Timeout closeWaitTimeout,
+ final long maxMessageSize,
+ final int maxOutboundControlQueue) {
+
+ this.connectTimeout = connectTimeout;
+ this.subprotocols = subprotocols != null
+ ? Collections.unmodifiableList(new ArrayList<>(subprotocols))
+ : Collections.emptyList();
+ this.perMessageDeflateEnabled = perMessageDeflateEnabled;
+ this.offerServerNoContextTakeover = offerServerNoContextTakeover;
+ this.offerClientNoContextTakeover = offerClientNoContextTakeover;
+ this.offerClientMaxWindowBits = offerClientMaxWindowBits;
+ this.offerServerMaxWindowBits = offerServerMaxWindowBits;
+ this.maxFrameSize = maxFrameSize;
+ this.outgoingChunkSize = outgoingChunkSize;
+ this.maxFramesPerTick = maxFramesPerTick;
+ this.ioPoolCapacity = ioPoolCapacity;
+ this.directBuffers = directBuffers;
+ this.autoPong = autoPong;
+ this.closeWaitTimeout = Args.notNull(closeWaitTimeout, "closeWaitTimeout");
+ this.maxMessageSize = maxMessageSize;
+ this.maxOutboundControlQueue = maxOutboundControlQueue;
+ }
+
+ /**
+ * Timeout used for establishing the initial TCP/TLS connection.
+ *
+ * @return connection timeout, may be {@code null} if the caller wants to rely on defaults
+ * @since 5.7
+ */
+ public Timeout getConnectTimeout() {
+ return connectTimeout;
+ }
+
+ /**
+ * Ordered list of WebSocket subprotocols offered to the server via {@code Sec-WebSocket-Protocol}.
+ *
+ * The server may select at most one. The client should treat a server-selected protocol that
+ * was not offered as a handshake failure.
+ *
+ * @return immutable list of offered subprotocols (never {@code null})
+ * @since 5.7
+ */
+ public List getSubprotocols() {
+ return subprotocols;
+ }
+
+ /**
+ * Whether the client offers the {@code permessage-deflate} extension during the handshake.
+ *
+ * @return {@code true} if PMCE is offered, {@code false} otherwise
+ * @since 5.7
+ */
+ public boolean isPerMessageDeflateEnabled() {
+ return perMessageDeflateEnabled;
+ }
+
+ /**
+ * Whether the client offers the {@code server_no_context_takeover} PMCE parameter.
+ *
+ * @return {@code true} if the parameter is included in the offer
+ * @since 5.7
+ */
+ public boolean isOfferServerNoContextTakeover() {
+ return offerServerNoContextTakeover;
+ }
+
+ /**
+ * Whether the client offers the {@code client_no_context_takeover} PMCE parameter.
+ *
+ * @return {@code true} if the parameter is included in the offer
+ * @since 5.7
+ */
+ public boolean isOfferClientNoContextTakeover() {
+ return offerClientNoContextTakeover;
+ }
+
+ /**
+ * Optional value for {@code client_max_window_bits} in the PMCE offer.
+ *
+ * Valid values are in range 8..15 when non-null. The client encoder
+ * currently supports only {@code 15} due to JDK Deflater limitations.
+ *
+ * @return offered {@code client_max_window_bits}, or {@code null} if not offered
+ * @since 5.7
+ */
+ public Integer getOfferClientMaxWindowBits() {
+ return offerClientMaxWindowBits;
+ }
+
+ /**
+ * Optional value for {@code server_max_window_bits} in the PMCE offer.
+ *
+ * Valid values are in range 8..15 when non-null. This value limits the
+ * server's compressor; the client decoder can accept any 8..15 value.
+ *
+ * @return offered {@code server_max_window_bits}, or {@code null} if not offered
+ * @since 5.7
+ */
+ public Integer getOfferServerMaxWindowBits() {
+ return offerServerMaxWindowBits;
+ }
+
+ /**
+ * Maximum accepted WebSocket frame payload size.
+ *
+ * If an incoming frame exceeds this limit, the implementation should treat it as a protocol
+ * violation and initiate a close with an appropriate close code.
+ *
+ * @return maximum frame payload size in bytes (must be > 0)
+ * @since 5.7
+ */
+ public int getMaxFrameSize() {
+ return maxFrameSize;
+ }
+
+ /**
+ * Preferred outgoing fragmentation chunk size.
+ *
+ * Outgoing messages larger than this value may be fragmented into multiple frames.
+ *
+ * @return outgoing chunk size in bytes (must be > 0)
+ * @since 5.7
+ */
+ public int getOutgoingChunkSize() {
+ return outgoingChunkSize;
+ }
+
+ /**
+ * Limit of frames written per reactor "tick".
+ *
+ * This is a fairness control to reduce the risk of starving the reactor thread when
+ * a large backlog exists.
+ *
+ * @return maximum frames per tick (must be > 0)
+ * @since 5.7
+ */
+ public int getMaxFramesPerTick() {
+ return maxFramesPerTick;
+ }
+
+ /**
+ * Capacity of the internal buffer pool used by WebSocket I/O.
+ *
+ * @return pool capacity (must be > 0)
+ * @since 5.7
+ */
+ public int getIoPoolCapacity() {
+ return ioPoolCapacity;
+ }
+
+ /**
+ * Whether direct byte buffers are preferred for the internal buffer pool.
+ *
+ * @return {@code true} for direct buffers, {@code false} for heap buffers
+ * @since 5.7
+ */
+ public boolean isDirectBuffers() {
+ return directBuffers;
+ }
+
+ /**
+ * Whether the client automatically responds to PING frames with a PONG frame.
+ *
+ * @return {@code true} if auto-PONG is enabled
+ * @since 5.7
+ */
+ public boolean isAutoPong() {
+ return autoPong;
+ }
+
+ /**
+ * Socket timeout used while waiting for the peer to complete the close handshake.
+ *
+ * @return close wait timeout (never {@code null})
+ * @since 5.7
+ */
+ public Timeout getCloseWaitTimeout() {
+ return closeWaitTimeout;
+ }
+
+ /**
+ * Maximum accepted message size after fragment reassembly (and after decompression if enabled).
+ *
+ * @return maximum message size in bytes (must be > 0)
+ * @since 5.7
+ */
+ public long getMaxMessageSize() {
+ return maxMessageSize;
+ }
+
+ /**
+ * Maximum number of queued outbound control frames.
+ *
+ * This bounds memory usage and prevents unbounded growth of control traffic under backpressure.
+ *
+ * @return maximum outbound control queue size (must be > 0)
+ * @since 5.7
+ */
+ public int getMaxOutboundControlQueue() {
+ return maxOutboundControlQueue;
+ }
+
+ /**
+ * Creates a new builder instance with default settings.
+ *
+ * @return builder
+ * @since 5.7
+ */
+ public static Builder custom() {
+ return new Builder();
+ }
+
+ /**
+ * Builder for {@link WebSocketClientConfig}.
+ *
+ * The builder is mutable and not thread-safe.
+ *
+ * @since 5.7
+ */
+ public static final class Builder {
+
+ private Timeout connectTimeout = Timeout.ofSeconds(10);
+ private List subprotocols = new ArrayList<>();
+
+ private boolean perMessageDeflateEnabled = true;
+ private boolean offerServerNoContextTakeover = true;
+ private boolean offerClientNoContextTakeover = true;
+ private Integer offerClientMaxWindowBits = 15;
+ private Integer offerServerMaxWindowBits = null;
+
+ private int maxFrameSize = 64 * 1024;
+ private int outgoingChunkSize = 8 * 1024;
+ private int maxFramesPerTick = 1024;
+
+ private int ioPoolCapacity = 64;
+ private boolean directBuffers = true;
+
+ private boolean autoPong = true;
+ private Timeout closeWaitTimeout = Timeout.ofSeconds(10);
+ private long maxMessageSize = 8L * 1024 * 1024;
+
+ private int maxOutboundControlQueue = 256;
+
+ /**
+ * Sets the timeout used to establish the initial TCP/TLS connection.
+ *
+ * @param v timeout, may be {@code null} to rely on defaults
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder setConnectTimeout(final Timeout v) {
+ this.connectTimeout = v;
+ return this;
+ }
+
+ /**
+ * Sets the ordered list of subprotocols offered to the server.
+ *
+ * @param v list of subprotocol names, may be {@code null} to offer none
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder setSubprotocols(final List v) {
+ this.subprotocols = v;
+ return this;
+ }
+
+ /**
+ * Enables or disables offering {@code permessage-deflate} during the handshake.
+ *
+ * @param v {@code true} to offer PMCE, {@code false} otherwise
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder enablePerMessageDeflate(final boolean v) {
+ this.perMessageDeflateEnabled = v;
+ return this;
+ }
+
+ /**
+ * Offers {@code server_no_context_takeover} in the PMCE offer.
+ *
+ * @param v whether to include the parameter in the offer
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder offerServerNoContextTakeover(final boolean v) {
+ this.offerServerNoContextTakeover = v;
+ return this;
+ }
+
+ /**
+ * Offers {@code client_no_context_takeover} in the PMCE offer.
+ *
+ * @param v whether to include the parameter in the offer
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder offerClientNoContextTakeover(final boolean v) {
+ this.offerClientNoContextTakeover = v;
+ return this;
+ }
+
+ /**
+ * Offers {@code client_max_window_bits} in the PMCE offer.
+ *
+ * Valid values are in range 8..15 when non-null. The client encoder
+ * currently supports only {@code 15} due to JDK Deflater limitations.
+ *
+ * @param v window bits, or {@code null} to omit the parameter
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder offerClientMaxWindowBits(final Integer v) {
+ this.offerClientMaxWindowBits = v;
+ return this;
+ }
+
+ /**
+ * Offers {@code server_max_window_bits} in the PMCE offer.
+ *
+ * Valid values are in range 8..15 when non-null. This value limits the
+ * server's compressor; the client decoder can accept any 8..15 value.
+ *
+ * @param v window bits, or {@code null} to omit the parameter
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder offerServerMaxWindowBits(final Integer v) {
+ this.offerServerMaxWindowBits = v;
+ return this;
+ }
+
+ /**
+ * Sets the maximum accepted frame payload size.
+ *
+ * @param v maximum frame payload size in bytes (must be > 0)
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder setMaxFrameSize(final int v) {
+ this.maxFrameSize = v;
+ return this;
+ }
+
+ /**
+ * Sets the preferred outgoing fragmentation chunk size.
+ *
+ * @param v chunk size in bytes (must be > 0)
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder setOutgoingChunkSize(final int v) {
+ this.outgoingChunkSize = v;
+ return this;
+ }
+
+ /**
+ * Sets the limit of frames written per reactor tick.
+ *
+ * @param v max frames per tick (must be > 0)
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder setMaxFramesPerTick(final int v) {
+ this.maxFramesPerTick = v;
+ return this;
+ }
+
+ /**
+ * Sets the capacity of the internal buffer pool.
+ *
+ * @param v pool capacity (must be > 0)
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder setIoPoolCapacity(final int v) {
+ this.ioPoolCapacity = v;
+ return this;
+ }
+
+ /**
+ * Enables or disables the use of direct buffers for the internal pool.
+ *
+ * @param v {@code true} for direct buffers, {@code false} for heap buffers
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder setDirectBuffers(final boolean v) {
+ this.directBuffers = v;
+ return this;
+ }
+
+ /**
+ * Enables or disables automatic PONG replies for received PING frames.
+ *
+ * @param v {@code true} to auto-reply with PONG
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder setAutoPong(final boolean v) {
+ this.autoPong = v;
+ return this;
+ }
+
+ /**
+ * Sets the close handshake wait timeout.
+ *
+ * @param v close wait timeout, must not be {@code null}
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder setCloseWaitTimeout(final Timeout v) {
+ this.closeWaitTimeout = v;
+ return this;
+ }
+
+ /**
+ * Sets the maximum accepted message size.
+ *
+ * @param v max message size in bytes (must be > 0)
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder setMaxMessageSize(final long v) {
+ this.maxMessageSize = v;
+ return this;
+ }
+
+ /**
+ * Sets the maximum number of queued outbound control frames.
+ *
+ * @param v max control queue size (must be > 0)
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder setMaxOutboundControlQueue(final int v) {
+ this.maxOutboundControlQueue = v;
+ return this;
+ }
+
+ /**
+ * Builds an immutable {@link WebSocketClientConfig}.
+ *
+ * @return configuration instance
+ * @throws IllegalArgumentException if any parameter is invalid
+ * @since 5.7
+ */
+ public WebSocketClientConfig build() {
+ if (offerClientMaxWindowBits != null && (offerClientMaxWindowBits < 8 || offerClientMaxWindowBits > 15)) {
+ throw new IllegalArgumentException("offerClientMaxWindowBits must be in range [8..15]");
+ }
+ if (offerServerMaxWindowBits != null && (offerServerMaxWindowBits < 8 || offerServerMaxWindowBits > 15)) {
+ throw new IllegalArgumentException("offerServerMaxWindowBits must be in range [8..15]");
+ }
+ if (closeWaitTimeout == null) {
+ throw new IllegalArgumentException("closeWaitTimeout != null");
+ }
+ if (maxFrameSize <= 0) {
+ throw new IllegalArgumentException("maxFrameSize > 0");
+ }
+ if (outgoingChunkSize <= 0) {
+ throw new IllegalArgumentException("outgoingChunkSize > 0");
+ }
+ if (maxFramesPerTick <= 0) {
+ throw new IllegalArgumentException("maxFramesPerTick > 0");
+ }
+ if (ioPoolCapacity <= 0) {
+ throw new IllegalArgumentException("ioPoolCapacity > 0");
+ }
+ if (maxMessageSize <= 0) {
+ throw new IllegalArgumentException("maxMessageSize > 0");
+ }
+ if (maxOutboundControlQueue <= 0) {
+ throw new IllegalArgumentException("maxOutboundControlQueue > 0");
+ }
+ return new WebSocketClientConfig(
+ connectTimeout, subprotocols,
+ perMessageDeflateEnabled, offerServerNoContextTakeover, offerClientNoContextTakeover,
+ offerClientMaxWindowBits, offerServerMaxWindowBits,
+ maxFrameSize, outgoingChunkSize, maxFramesPerTick,
+ ioPoolCapacity, directBuffers,
+ autoPong, closeWaitTimeout, maxMessageSize,
+ maxOutboundControlQueue
+ );
+ }
+ }
+}
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java
new file mode 100644
index 0000000000..ca621672c2
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java
@@ -0,0 +1,99 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.api;
+
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+
+/**
+ * Callback interface for receiving WebSocket events.
+ *
+ * Implementations should be fast and non-blocking because callbacks
+ * are normally invoked on I/O dispatcher threads.
+ *
+ * @since 5.7
+ */
+public interface WebSocketListener {
+
+ /**
+ * Invoked when the WebSocket connection has been established.
+ */
+ default void onOpen(WebSocket webSocket) {
+ }
+
+ /**
+ * Invoked when a complete text message has been received.
+ *
+ * @param data characters of the message; the buffer is only valid
+ * for the duration of the callback.
+ * @param last always {@code true} for now; reserved for future
+ * streaming support.
+ */
+ default void onText(CharBuffer data, boolean last) {
+ }
+
+ /**
+ * Invoked when a complete binary message has been received.
+ *
+ * @param data binary payload; the buffer is only valid for the
+ * duration of the callback.
+ * @param last always {@code true} for now; reserved for future
+ * streaming support.
+ */
+ default void onBinary(ByteBuffer data, boolean last) {
+ }
+
+ /**
+ * Invoked when a PING control frame is received.
+ */
+ default void onPing(ByteBuffer data) {
+ }
+
+ /**
+ * Invoked when a PONG control frame is received.
+ */
+ default void onPong(ByteBuffer data) {
+ }
+
+ /**
+ * Invoked when the WebSocket has been closed.
+ *
+ * @param statusCode close status code.
+ * @param reason close reason, never {@code null} but may be empty.
+ */
+ default void onClose(int statusCode, String reason) {
+ }
+
+ /**
+ * Invoked when a fatal error occurs on the WebSocket connection.
+ *
+ * After this callback the connection is considered closed.
+ */
+ default void onError(Throwable cause) {
+ }
+}
+
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/package-info.java
new file mode 100644
index 0000000000..aefc2615b5
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * Public WebSocket API for client applications.
+ *
+ * Types in this package are stable and intended for direct use:
+ * {@code WebSocket}, {@code WebSocketClientConfig}, and {@code WebSocketListener}.
+ *
+ * @since 5.7
+ */
+package org.apache.hc.client5.http.websocket.api;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.java
new file mode 100644
index 0000000000..9209d8a552
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.java
@@ -0,0 +1,120 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.client;
+
+import java.net.URI;
+import java.util.concurrent.CompletableFuture;
+
+import org.apache.hc.client5.http.websocket.api.WebSocket;
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.core5.annotation.Contract;
+import org.apache.hc.core5.annotation.ThreadingBehavior;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.http.protocol.HttpCoreContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.io.ModalCloseable;
+import org.apache.hc.core5.reactor.IOReactorStatus;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.TimeValue;
+
+/**
+ * Public WebSocket client API mirroring {@code CloseableHttpAsyncClient}'s shape.
+ *
+ * Subclasses provide the actual connect implementation in {@link #doConnect(URI, WebSocketListener, WebSocketClientConfig, HttpContext)}.
+ * Overloads of {@code connect(...)} funnel into that single method.
+ *
+ * This type is a {@link ModalCloseable}; use {@link #close(CloseMode)} to select graceful or immediate shutdown.
+ *
+ * @since 5.7
+ */
+@Contract(threading = ThreadingBehavior.STATELESS)
+public abstract class CloseableWebSocketClient implements WebSocketClient, ModalCloseable {
+
+ /**
+ * Start underlying I/O. Safe to call once; subsequent calls are no-ops.
+ */
+ public abstract void start();
+
+ /**
+ * Current I/O reactor status.
+ */
+ public abstract IOReactorStatus getStatus();
+
+ /**
+ * Best-effort await of shutdown.
+ */
+ public abstract void awaitShutdown(TimeValue waitTime) throws InterruptedException;
+
+ /**
+ * Initiate shutdown (non-blocking).
+ */
+ public abstract void initiateShutdown();
+
+ /**
+ * Core connect hook for subclasses.
+ *
+ * @param uri target WebSocket URI (ws:// or wss://)
+ * @param listener application callbacks
+ * @param cfg optional per-connection config (may be {@code null} for defaults)
+ * @param context optional HTTP context (may be {@code null})
+ */
+ protected abstract CompletableFuture doConnect(
+ URI uri,
+ WebSocketListener listener,
+ WebSocketClientConfig cfg,
+ HttpContext context);
+
+ public final CompletableFuture connect(
+ final URI uri,
+ final WebSocketListener listener) {
+ Args.notNull(uri, "URI");
+ Args.notNull(listener, "WebSocketListener");
+ return connect(uri, listener, null, HttpCoreContext.create());
+ }
+
+ public final CompletableFuture connect(
+ final URI uri,
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfg) {
+ Args.notNull(uri, "URI");
+ Args.notNull(listener, "WebSocketListener");
+ return connect(uri, listener, cfg, HttpCoreContext.create());
+ }
+
+ @Override
+ public final CompletableFuture connect(
+ final URI uri,
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfg,
+ final HttpContext context) {
+ Args.notNull(uri, "URI");
+ Args.notNull(listener, "WebSocketListener");
+ return doConnect(uri, listener, cfg, context);
+ }
+
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.java
new file mode 100644
index 0000000000..938c1eae81
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.java
@@ -0,0 +1,74 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.client;
+
+import java.net.URI;
+import java.util.concurrent.CompletableFuture;
+
+import org.apache.hc.client5.http.websocket.api.WebSocket;
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.core5.http.protocol.HttpContext;
+
+/**
+ * Client for establishing WebSocket connections using the underlying
+ * asynchronous HttpClient infrastructure.
+ *
+ * @since 5.7
+ */
+public interface WebSocketClient {
+
+ /**
+ * Initiates an asynchronous WebSocket connection to the given target URI.
+ *
+ * The URI must use the {@code ws} or {@code wss} scheme. This method
+ * performs an HTTP/1.1 upgrade to the WebSocket protocol and, on success,
+ * creates a {@link WebSocket} associated with the supplied
+ * {@link WebSocketListener}.
+ *
+ * The operation is fully asynchronous. The returned
+ * {@link CompletableFuture} completes when the opening WebSocket
+ * handshake has either succeeded or failed.
+ *
+ * @param uri target WebSocket URI, must not be {@code null}.
+ * @param listener callback that receives WebSocket events, must not be {@code null}.
+ * @param cfg optional per-connection configuration; if {@code null}, the
+ * client’s default configuration is used.
+ * @param context optional HTTP context for the underlying upgrade request;
+ * may be {@code null}.
+ * @return a future that completes with a connected {@link WebSocket} on
+ * success, or completes exceptionally if the connection attempt
+ * or protocol handshake fails.
+ * @since 5.7
+ */
+ CompletableFuture connect(
+ URI uri,
+ WebSocketListener listener,
+ WebSocketClientConfig cfg,
+ HttpContext context);
+
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java
new file mode 100644
index 0000000000..f8f0e32209
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java
@@ -0,0 +1,447 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.client;
+
+import java.util.concurrent.ThreadFactory;
+
+import org.apache.hc.client5.http.impl.DefaultClientConnectionReuseStrategy;
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.client.impl.DefaultWebSocketClient;
+import org.apache.hc.client5.http.websocket.client.impl.logging.WsLoggingExceptionCallback;
+import org.apache.hc.core5.concurrent.DefaultThreadFactory;
+import org.apache.hc.core5.function.Callback;
+import org.apache.hc.core5.function.Decorator;
+import org.apache.hc.core5.http.ConnectionReuseStrategy;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.config.CharCodingConfig;
+import org.apache.hc.core5.http.config.Http1Config;
+import org.apache.hc.core5.http.impl.Http1StreamListener;
+import org.apache.hc.core5.http.impl.HttpProcessors;
+import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester;
+import org.apache.hc.core5.http.impl.nio.ClientHttp1IOEventHandlerFactory;
+import org.apache.hc.core5.http.impl.nio.ClientHttp1StreamDuplexerFactory;
+import org.apache.hc.core5.http.nio.ssl.BasicClientTlsStrategy;
+import org.apache.hc.core5.http.nio.ssl.TlsStrategy;
+import org.apache.hc.core5.http.protocol.HttpProcessor;
+import org.apache.hc.core5.pool.ConnPoolListener;
+import org.apache.hc.core5.pool.DefaultDisposalCallback;
+import org.apache.hc.core5.pool.LaxConnPool;
+import org.apache.hc.core5.pool.ManagedConnPool;
+import org.apache.hc.core5.pool.PoolConcurrencyPolicy;
+import org.apache.hc.core5.pool.PoolReusePolicy;
+import org.apache.hc.core5.pool.StrictConnPool;
+import org.apache.hc.core5.reactor.IOEventHandlerFactory;
+import org.apache.hc.core5.reactor.IOReactorConfig;
+import org.apache.hc.core5.reactor.IOReactorMetricsListener;
+import org.apache.hc.core5.reactor.IOSession;
+import org.apache.hc.core5.reactor.IOSessionListener;
+import org.apache.hc.core5.reactor.IOWorkerSelector;
+import org.apache.hc.core5.util.Timeout;
+
+/**
+ * Builder for {@link CloseableWebSocketClient} instances.
+ *
+ * This builder assembles a WebSocket client on top of the asynchronous
+ * HTTP/1.1 requester and connection pool infrastructure provided by
+ * HttpComponents Core. Unless otherwise specified, sensible defaults
+ * are used for all components.
+ *
+ *
+ * The resulting {@link CloseableWebSocketClient} manages its own I/O
+ * reactor and connection pool and must be {@link java.io.Closeable#close()
+ * closed} when no longer needed.
+ *
+ * @since 5.7
+ */
+public final class WebSocketClientBuilder {
+
+ private IOReactorConfig ioReactorConfig;
+ private Http1Config http1Config;
+ private CharCodingConfig charCodingConfig;
+ private HttpProcessor httpProcessor;
+ private ConnectionReuseStrategy connStrategy;
+ private int defaultMaxPerRoute;
+ private int maxTotal;
+ private Timeout timeToLive;
+ private PoolReusePolicy poolReusePolicy;
+ private PoolConcurrencyPolicy poolConcurrencyPolicy;
+ private TlsStrategy tlsStrategy;
+ private Timeout handshakeTimeout;
+ private Decorator ioSessionDecorator;
+ private Callback exceptionCallback;
+ private IOSessionListener sessionListener;
+ private Http1StreamListener streamListener;
+ private ConnPoolListener connPoolListener;
+ private ThreadFactory threadFactory;
+
+ // Optional listeners for reactor metrics and worker selection.
+ private IOReactorMetricsListener reactorMetricsListener;
+ private IOWorkerSelector workerSelector;
+
+ private WebSocketClientConfig defaultConfig = WebSocketClientConfig.custom().build();
+
+ private WebSocketClientBuilder() {
+ }
+
+ /**
+ * Creates a new {@code WebSocketClientBuilder} instance.
+ *
+ * @return a new builder.
+ */
+ public static WebSocketClientBuilder create() {
+ return new WebSocketClientBuilder();
+ }
+
+ /**
+ * Sets the default configuration applied to WebSocket connections
+ * created by the resulting client.
+ *
+ * @param defaultConfig default WebSocket configuration; if {@code null}
+ * the existing default is retained.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder defaultConfig(final WebSocketClientConfig defaultConfig) {
+ if (defaultConfig != null) {
+ this.defaultConfig = defaultConfig;
+ }
+ return this;
+ }
+
+ /**
+ * Sets the I/O reactor configuration.
+ *
+ * @param ioReactorConfig I/O reactor configuration, or {@code null}
+ * to use {@link IOReactorConfig#DEFAULT}.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setIOReactorConfig(final IOReactorConfig ioReactorConfig) {
+ this.ioReactorConfig = ioReactorConfig;
+ return this;
+ }
+
+ /**
+ * Sets the HTTP/1.1 configuration for the underlying requester.
+ *
+ * @param http1Config HTTP/1.1 configuration, or {@code null}
+ * to use {@link Http1Config#DEFAULT}.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setHttp1Config(final Http1Config http1Config) {
+ this.http1Config = http1Config;
+ return this;
+ }
+
+ /**
+ * Sets the character coding configuration for HTTP message processing.
+ *
+ * @param charCodingConfig character coding configuration, or {@code null}
+ * to use {@link CharCodingConfig#DEFAULT}.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setCharCodingConfig(final CharCodingConfig charCodingConfig) {
+ this.charCodingConfig = charCodingConfig;
+ return this;
+ }
+
+ /**
+ * Sets a custom {@link HttpProcessor} for HTTP/1.1 requests.
+ *
+ * @param httpProcessor HTTP processor, or {@code null} to use
+ * {@link HttpProcessors#client()}.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setHttpProcessor(final HttpProcessor httpProcessor) {
+ this.httpProcessor = httpProcessor;
+ return this;
+ }
+
+ /**
+ * Sets the connection reuse strategy for persistent HTTP connections.
+ *
+ * @param connStrategy connection reuse strategy, or {@code null}
+ * to use {@link DefaultClientConnectionReuseStrategy}.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setConnectionReuseStrategy(final ConnectionReuseStrategy connStrategy) {
+ this.connStrategy = connStrategy;
+ return this;
+ }
+
+ /**
+ * Sets the default maximum number of connections per route.
+ *
+ * @param defaultMaxPerRoute maximum connections per route; values
+ * ≤ 0 cause the default of {@code 20}
+ * to be used.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setDefaultMaxPerRoute(final int defaultMaxPerRoute) {
+ this.defaultMaxPerRoute = defaultMaxPerRoute;
+ return this;
+ }
+
+ /**
+ * Sets the maximum total number of connections in the pool.
+ *
+ * @param maxTotal maximum total connections; values ≤ 0 cause
+ * the default of {@code 50} to be used.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setMaxTotal(final int maxTotal) {
+ this.maxTotal = maxTotal;
+ return this;
+ }
+
+ /**
+ * Sets the time-to-live for persistent connections in the pool.
+ *
+ * @param timeToLive connection time-to-live, or {@code null} to use
+ * {@link Timeout#DISABLED}.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setTimeToLive(final Timeout timeToLive) {
+ this.timeToLive = timeToLive;
+ return this;
+ }
+
+ /**
+ * Sets the reuse policy for connections in the pool.
+ *
+ * @param poolReusePolicy reuse policy, or {@code null} to use
+ * {@link PoolReusePolicy#LIFO}.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setPoolReusePolicy(final PoolReusePolicy poolReusePolicy) {
+ this.poolReusePolicy = poolReusePolicy;
+ return this;
+ }
+
+ /**
+ * Sets the concurrency policy for the connection pool.
+ *
+ * @param poolConcurrencyPolicy concurrency policy, or {@code null}
+ * to use {@link PoolConcurrencyPolicy#STRICT}.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setPoolConcurrencyPolicy(final PoolConcurrencyPolicy poolConcurrencyPolicy) {
+ this.poolConcurrencyPolicy = poolConcurrencyPolicy;
+ return this;
+ }
+
+ /**
+ * Sets the TLS strategy used to establish HTTPS or WSS connections.
+ *
+ * @param tlsStrategy TLS strategy, or {@code null} to use
+ * {@link BasicClientTlsStrategy}.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setTlsStrategy(final TlsStrategy tlsStrategy) {
+ this.tlsStrategy = tlsStrategy;
+ return this;
+ }
+
+ /**
+ * Sets the timeout for the TLS handshake.
+ *
+ * @param handshakeTimeout handshake timeout, or {@code null} for no
+ * specific timeout.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setTlsHandshakeTimeout(final Timeout handshakeTimeout) {
+ this.handshakeTimeout = handshakeTimeout;
+ return this;
+ }
+
+ /**
+ * Sets a decorator for low-level I/O sessions created by the reactor.
+ *
+ * @param ioSessionDecorator decorator, or {@code null} for none.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setIOSessionDecorator(final Decorator ioSessionDecorator) {
+ this.ioSessionDecorator = ioSessionDecorator;
+ return this;
+ }
+
+ /**
+ * Sets a callback to be notified of fatal I/O exceptions.
+ *
+ * @param exceptionCallback exception callback, or {@code null} to use
+ * {@link WsLoggingExceptionCallback#INSTANCE}.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setExceptionCallback(final Callback exceptionCallback) {
+ this.exceptionCallback = exceptionCallback;
+ return this;
+ }
+
+ /**
+ * Sets a listener for I/O session lifecycle events.
+ *
+ * @param sessionListener session listener, or {@code null} for none.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setIOSessionListener(final IOSessionListener sessionListener) {
+ this.sessionListener = sessionListener;
+ return this;
+ }
+
+ /**
+ * Sets a listener for HTTP/1.1 stream events.
+ *
+ * @param streamListener stream listener, or {@code null} for none.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setStreamListener(
+ final Http1StreamListener streamListener) {
+ this.streamListener = streamListener;
+ return this;
+ }
+
+ /**
+ * Sets a listener for connection pool events.
+ *
+ * @param connPoolListener pool listener, or {@code null} for none.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setConnPoolListener(final ConnPoolListener connPoolListener) {
+ this.connPoolListener = connPoolListener;
+ return this;
+ }
+
+ /**
+ * Sets the thread factory used to create the main I/O reactor thread.
+ *
+ * @param threadFactory thread factory, or {@code null} to use a
+ * {@link DefaultThreadFactory} named
+ * {@code "websocket-main"}.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setThreadFactory(final ThreadFactory threadFactory) {
+ this.threadFactory = threadFactory;
+ return this;
+ }
+
+ /**
+ * Sets a metrics listener for the I/O reactor.
+ *
+ * @param reactorMetricsListener metrics listener, or {@code null} for none.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setReactorMetricsListener(
+ final IOReactorMetricsListener reactorMetricsListener) {
+ this.reactorMetricsListener = reactorMetricsListener;
+ return this;
+ }
+
+ /**
+ * Sets a worker selector for assigning I/O sessions to worker threads.
+ *
+ * @param workerSelector worker selector, or {@code null} for the default
+ * strategy.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setWorkerSelector(final IOWorkerSelector workerSelector) {
+ this.workerSelector = workerSelector;
+ return this;
+ }
+
+ /**
+ * Builds a new {@link CloseableWebSocketClient} instance using the
+ * current builder configuration.
+ *
+ * The returned client owns its underlying I/O reactor and connection
+ * pool and must be closed to release system resources.
+ *
+ * @return a newly created {@link CloseableWebSocketClient}.
+ */
+ public CloseableWebSocketClient build() {
+
+ final PoolConcurrencyPolicy conc = poolConcurrencyPolicy != null
+ ? poolConcurrencyPolicy
+ : PoolConcurrencyPolicy.STRICT;
+ final PoolReusePolicy reuse = poolReusePolicy != null
+ ? poolReusePolicy
+ : PoolReusePolicy.LIFO;
+ final Timeout ttl = timeToLive != null ? timeToLive : Timeout.DISABLED;
+
+ final ManagedConnPool connPool;
+ if (conc == PoolConcurrencyPolicy.LAX) {
+ connPool = new LaxConnPool<>(
+ defaultMaxPerRoute > 0 ? defaultMaxPerRoute : 20,
+ ttl, reuse, new DefaultDisposalCallback<>(), connPoolListener);
+ } else {
+ connPool = new StrictConnPool<>(
+ defaultMaxPerRoute > 0 ? defaultMaxPerRoute : 20,
+ maxTotal > 0 ? maxTotal : 50,
+ ttl, reuse, new DefaultDisposalCallback<>(), connPoolListener);
+ }
+
+ final HttpProcessor proc = httpProcessor != null ? httpProcessor : HttpProcessors.client();
+ final Http1Config h1 = http1Config != null ? http1Config : Http1Config.DEFAULT;
+ final CharCodingConfig coding = charCodingConfig != null ? charCodingConfig : CharCodingConfig.DEFAULT;
+
+ final ConnectionReuseStrategy reuseStrategyCopy = connStrategy != null
+ ? connStrategy
+ : new DefaultClientConnectionReuseStrategy();
+
+ final ClientHttp1StreamDuplexerFactory duplexerFactory =
+ new ClientHttp1StreamDuplexerFactory(
+ proc, h1, coding, reuseStrategyCopy, null, null, streamListener);
+
+ final TlsStrategy tls = tlsStrategy != null ? tlsStrategy : new BasicClientTlsStrategy();
+ final IOEventHandlerFactory iohFactory =
+ new ClientHttp1IOEventHandlerFactory(duplexerFactory, tls, handshakeTimeout);
+
+ final IOReactorMetricsListener metricsListener = reactorMetricsListener != null ? reactorMetricsListener : null;
+ final IOWorkerSelector selector = workerSelector != null ? workerSelector : null;
+
+ final HttpAsyncRequester requester = new HttpAsyncRequester(
+ ioReactorConfig != null ? ioReactorConfig : IOReactorConfig.DEFAULT,
+ iohFactory,
+ ioSessionDecorator,
+ exceptionCallback != null ? exceptionCallback : WsLoggingExceptionCallback.INSTANCE,
+ sessionListener,
+ connPool,
+ tls,
+ handshakeTimeout,
+ metricsListener,
+ selector
+ );
+
+ final ThreadFactory tf = threadFactory != null
+ ? threadFactory
+ : new DefaultThreadFactory("websocket-main", true);
+
+ return new DefaultWebSocketClient(
+ requester,
+ connPool,
+ defaultConfig,
+ tf
+ );
+ }
+}
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java
new file mode 100644
index 0000000000..99bb30dc8d
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java
@@ -0,0 +1,78 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.client;
+
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+
+/**
+ * Static factory methods for {@link CloseableWebSocketClient} instances.
+ *
+ * This is a convenience entry point for typical client creation
+ * scenarios. For advanced configuration use
+ * {@link WebSocketClientBuilder} directly.
+ *
+ * @since 5.7
+ */
+public final class WebSocketClients {
+
+ private WebSocketClients() {
+ }
+
+ /**
+ * Creates a new {@link WebSocketClientBuilder} instance for
+ * custom client configuration.
+ *
+ * @return a new {@link WebSocketClientBuilder}.
+ */
+ public static WebSocketClientBuilder custom() {
+ return WebSocketClientBuilder.create();
+ }
+
+ /**
+ * Creates a {@link CloseableWebSocketClient} instance with
+ * default configuration.
+ *
+ * @return a newly created {@link CloseableWebSocketClient}
+ * using default settings.
+ */
+ public static CloseableWebSocketClient createDefault() {
+ return custom().build();
+ }
+
+ /**
+ * Creates a {@link CloseableWebSocketClient} instance using
+ * the given default WebSocket configuration.
+ *
+ * @param defaultConfig default configuration applied to
+ * WebSocket connections created by
+ * the client; must not be {@code null}.
+ * @return a newly created {@link CloseableWebSocketClient}.
+ */
+ public static CloseableWebSocketClient createWith(final WebSocketClientConfig defaultConfig) {
+ return custom().defaultConfig(defaultConfig).build();
+ }
+}
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/AbstractWebSocketClient.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/AbstractWebSocketClient.java
new file mode 100644
index 0000000000..91b82027a1
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/AbstractWebSocketClient.java
@@ -0,0 +1,107 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.client.impl;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient;
+import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.reactor.IOReactorStatus;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.TimeValue;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public abstract class AbstractWebSocketClient extends CloseableWebSocketClient {
+
+ enum Status { READY, RUNNING, TERMINATED }
+
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractWebSocketClient.class);
+
+ private final HttpAsyncRequester requester;
+ private final ExecutorService executorService;
+ private final AtomicReference status;
+
+ AbstractWebSocketClient(final HttpAsyncRequester requester, final ThreadFactory threadFactory) {
+ super();
+ this.requester = Args.notNull(requester, "requester");
+ this.executorService = Executors.newSingleThreadExecutor(threadFactory);
+ this.status = new AtomicReference<>(Status.READY);
+ }
+
+ @Override
+ public final void start() {
+ if (status.compareAndSet(Status.READY, Status.RUNNING)) {
+ executorService.execute(requester::start);
+ }
+ }
+
+ boolean isRunning() {
+ return status.get() == Status.RUNNING;
+ }
+
+ @Override
+ public final IOReactorStatus getStatus() {
+ return requester.getStatus();
+ }
+
+ @Override
+ public final void awaitShutdown(final TimeValue waitTime) throws InterruptedException {
+ requester.awaitShutdown(waitTime);
+ }
+
+ @Override
+ public final void initiateShutdown() {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Initiating shutdown");
+ }
+ requester.initiateShutdown();
+ }
+
+ void internalClose(final CloseMode closeMode) {
+ }
+
+ @Override
+ public final void close(final CloseMode closeMode) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Shutdown {}", closeMode);
+ }
+ requester.initiateShutdown();
+ requester.close(closeMode != null ? closeMode : CloseMode.IMMEDIATE);
+ executorService.shutdownNow();
+ internalClose(closeMode);
+ }
+
+ @Override
+ public void close() {
+ close(CloseMode.GRACEFUL);
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/DefaultWebSocketClient.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/DefaultWebSocketClient.java
new file mode 100644
index 0000000000..9671f31040
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/DefaultWebSocketClient.java
@@ -0,0 +1,51 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.client.impl;
+
+import java.util.concurrent.ThreadFactory;
+
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.core5.annotation.Contract;
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.annotation.ThreadingBehavior;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester;
+import org.apache.hc.core5.pool.ManagedConnPool;
+import org.apache.hc.core5.reactor.IOSession;
+
+@Contract(threading = ThreadingBehavior.SAFE_CONDITIONAL)
+@Internal
+public class DefaultWebSocketClient extends InternalWebSocketClientBase {
+
+ public DefaultWebSocketClient(
+ final HttpAsyncRequester requester,
+ final ManagedConnPool connPool,
+ final WebSocketClientConfig defaultConfig,
+ final ThreadFactory threadFactory) {
+ super(requester, connPool, defaultConfig, threadFactory);
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/InternalWebSocketClientBase.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/InternalWebSocketClientBase.java
new file mode 100644
index 0000000000..26ba8a9362
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/InternalWebSocketClientBase.java
@@ -0,0 +1,104 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.client.impl;
+
+import java.net.URI;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ThreadFactory;
+
+import org.apache.hc.client5.http.websocket.api.WebSocket;
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.client5.http.websocket.client.impl.protocol.Http1UpgradeProtocol;
+import org.apache.hc.client5.http.websocket.client.impl.protocol.WebSocketProtocolStrategy;
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.pool.ManagedConnPool;
+import org.apache.hc.core5.reactor.IOSession;
+import org.apache.hc.core5.util.Args;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Minimal internal WS client: owns requester + pool, no extra closeables.
+ */
+@Internal
+abstract class InternalWebSocketClientBase extends AbstractWebSocketClient {
+
+ private static final Logger LOG = LoggerFactory.getLogger(InternalWebSocketClientBase.class);
+
+ private final WebSocketClientConfig defaultConfig;
+ private final ManagedConnPool connPool;
+
+ private final WebSocketProtocolStrategy h1;
+
+ InternalWebSocketClientBase(
+ final HttpAsyncRequester requester,
+ final ManagedConnPool connPool,
+ final WebSocketClientConfig defaultConfig,
+ final ThreadFactory threadFactory) {
+ super(Args.notNull(requester, "requester"), threadFactory);
+ this.connPool = Args.notNull(connPool, "connPool");
+ this.defaultConfig = defaultConfig != null ? defaultConfig : WebSocketClientConfig.custom().build();
+ this.h1 = newH1Protocol(requester, connPool);
+ }
+
+ /**
+ * HTTP/1.1 Upgrade protocol.
+ */
+ protected WebSocketProtocolStrategy newH1Protocol(
+ final HttpAsyncRequester requester,
+ final ManagedConnPool connPool) {
+ return new Http1UpgradeProtocol(requester, connPool);
+ }
+
+ @Override
+ protected CompletableFuture doConnect(
+ final URI uri,
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfgOrNull,
+ final HttpContext context) {
+
+ final WebSocketClientConfig cfg = cfgOrNull != null ? cfgOrNull : defaultConfig;
+ return h1.connect(uri, listener, cfg, context);
+ }
+
+ @Override
+ protected void internalClose(final CloseMode closeMode) {
+ try {
+ final CloseMode mode = closeMode != null ? closeMode : CloseMode.GRACEFUL;
+ connPool.close(mode);
+ } catch (final Exception ex) {
+ if (LOG.isWarnEnabled()) {
+ LOG.warn("Error closing pool: {}", ex.getMessage(), ex);
+ }
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnector.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnector.java
new file mode 100644
index 0000000000..635d81ca6e
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnector.java
@@ -0,0 +1,215 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.client.impl.connector;
+
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.concurrent.ComplexFuture;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.ConnectionClosedException;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester;
+import org.apache.hc.core5.http.nio.AsyncClientEndpoint;
+import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler;
+import org.apache.hc.core5.http.nio.AsyncPushConsumer;
+import org.apache.hc.core5.http.nio.HandlerFactory;
+import org.apache.hc.core5.http.nio.command.RequestExecutionCommand;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.pool.ManagedConnPool;
+import org.apache.hc.core5.pool.PoolEntry;
+import org.apache.hc.core5.reactor.Command;
+import org.apache.hc.core5.reactor.EndpointParameters;
+import org.apache.hc.core5.reactor.IOSession;
+import org.apache.hc.core5.reactor.ProtocolIOSession;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.Timeout;
+
+/**
+ * Facade that leases an IOSession from the pool and exposes a ProtocolIOSession through AsyncClientEndpoint.
+ *
+ * @since 5.7
+ */
+@Internal
+public final class WebSocketEndpointConnector {
+
+ private final HttpAsyncRequester requester;
+ private final ManagedConnPool connPool;
+
+ public WebSocketEndpointConnector(final HttpAsyncRequester requester, final ManagedConnPool connPool) {
+ this.requester = Args.notNull(requester, "requester");
+ this.connPool = Args.notNull(connPool, "connPool");
+ }
+
+ public final class ProtoEndpoint extends AsyncClientEndpoint {
+
+ private final AtomicReference> poolEntryRef;
+
+ ProtoEndpoint(final PoolEntry poolEntry) {
+ this.poolEntryRef = new AtomicReference<>(poolEntry);
+ }
+
+ private PoolEntry getPoolEntryOrThrow() {
+ final PoolEntry pe = poolEntryRef.get();
+ if (pe == null) {
+ throw new IllegalStateException("Endpoint has already been released");
+ }
+ return pe;
+ }
+
+ private IOSession getIOSessionOrThrow() {
+ final IOSession io = getPoolEntryOrThrow().getConnection();
+ if (io == null) {
+ throw new IllegalStateException("I/O session is invalid");
+ }
+ return io;
+ }
+
+ /**
+ * Expose the ProtocolIOSession for protocol switching.
+ */
+ public ProtocolIOSession getProtocolIOSession() {
+ final IOSession io = getIOSessionOrThrow();
+ if (!(io instanceof ProtocolIOSession)) {
+ throw new IllegalStateException("Underlying IOSession is not a ProtocolIOSession: " + io);
+ }
+ return (ProtocolIOSession) io;
+ }
+
+ @Override
+ public void execute(final AsyncClientExchangeHandler exchangeHandler,
+ final HandlerFactory pushHandlerFactory,
+ final HttpContext context) {
+ Args.notNull(exchangeHandler, "Exchange handler");
+ final IOSession ioSession = getIOSessionOrThrow();
+ ioSession.enqueue(new RequestExecutionCommand(exchangeHandler, pushHandlerFactory, null, context), Command.Priority.NORMAL);
+ if (!ioSession.isOpen()) {
+ try {
+ exchangeHandler.failed(new ConnectionClosedException());
+ } finally {
+ exchangeHandler.releaseResources();
+ }
+ }
+ }
+
+ @Override
+ public boolean isConnected() {
+ final PoolEntry pe = poolEntryRef.get();
+ final IOSession io = pe != null ? pe.getConnection() : null;
+ return io != null && io.isOpen();
+ }
+
+ @Override
+ public void releaseAndReuse() {
+ final PoolEntry pe = poolEntryRef.getAndSet(null);
+ if (pe != null) {
+ final IOSession io = pe.getConnection();
+ connPool.release(pe, io != null && io.isOpen());
+ }
+ }
+
+ @Override
+ public void releaseAndDiscard() {
+ final PoolEntry pe = poolEntryRef.getAndSet(null);
+ if (pe != null) {
+ pe.discardConnection(CloseMode.GRACEFUL);
+ connPool.release(pe, false);
+ }
+ }
+ }
+
+ public Future connect(final HttpHost host,
+ final Timeout timeout,
+ final Object attachment,
+ final FutureCallback callback) {
+ Args.notNull(host, "Host");
+ Args.notNull(timeout, "Timeout");
+
+ final ComplexFuture resultFuture = new ComplexFuture<>(callback);
+
+ final Future> leaseFuture = connPool.lease(host, null, timeout,
+ new FutureCallback>() {
+ @Override
+ public void completed(final PoolEntry poolEntry) {
+ final ProtoEndpoint endpoint = new ProtoEndpoint(poolEntry);
+ final IOSession ioSession = poolEntry.getConnection();
+ if (ioSession != null && !ioSession.isOpen()) {
+ poolEntry.discardConnection(CloseMode.IMMEDIATE);
+ }
+ if (poolEntry.hasConnection()) {
+ resultFuture.completed(endpoint);
+ } else {
+ final Future future = requester.requestSession(
+ host, timeout,
+ new EndpointParameters(host, attachment),
+ new FutureCallback() {
+ @Override
+ public void completed(final IOSession session) {
+ session.setSocketTimeout(timeout);
+ poolEntry.assignConnection(session);
+ resultFuture.completed(endpoint);
+ }
+
+ @Override
+ public void failed(final Exception cause) {
+ try {
+ resultFuture.failed(cause);
+ } finally {
+ endpoint.releaseAndDiscard();
+ }
+ }
+
+ @Override
+ public void cancelled() {
+ try {
+ resultFuture.cancel();
+ } finally {
+ endpoint.releaseAndDiscard();
+ }
+ }
+ });
+ resultFuture.setDependency(future);
+ }
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ resultFuture.failed(ex);
+ }
+
+ @Override
+ public void cancelled() {
+ resultFuture.cancel();
+ }
+ });
+
+ resultFuture.setDependency(leaseFuture);
+ return resultFuture;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/package-info.java
new file mode 100644
index 0000000000..11bfe7dfc2
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/connector/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * Message-level helpers and codecs.
+ *
+ * Utilities for parsing and validating message semantics (e.g., CLOSE
+ * status code and reason handling).
+ *
+ * @since 5.7
+ */
+package org.apache.hc.client5.http.websocket.client.impl.connector;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/WsLoggingExceptionCallback.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/WsLoggingExceptionCallback.java
new file mode 100644
index 0000000000..3f90b17b63
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/WsLoggingExceptionCallback.java
@@ -0,0 +1,53 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.client.impl.logging;
+
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.function.Callback;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Internal
+public class WsLoggingExceptionCallback implements Callback {
+
+ /**
+ * Singleton instance of LoggingExceptionCallback.
+ */
+ public static final WsLoggingExceptionCallback INSTANCE = new WsLoggingExceptionCallback();
+
+ private static final Logger LOG = LoggerFactory.getLogger("org.apache.hc.client5.http.websocket.client");
+
+ private WsLoggingExceptionCallback() {
+ }
+
+ @Override
+ public void execute(final Exception ex) {
+ LOG.error(ex.getMessage(), ex);
+ }
+
+}
+
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/package-info.java
new file mode 100644
index 0000000000..47a68d46f5
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * Message-level helpers and codecs.
+ *
+ * Utilities for parsing and validating message semantics (e.g., CLOSE
+ * status code and reason handling).
+ *
+ * @since 5.7
+ */
+package org.apache.hc.client5.http.websocket.client.impl.logging;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/package-info.java
new file mode 100644
index 0000000000..c569c74cc3
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * Public WebSocket API for client applications.
+ *
+ * Types in this package are stable and intended for direct use:
+ * {@code WebSocket}, {@code WebSocketClientConfig}, and {@code WebSocketListener}.
+ *
+ * @since 5.7
+ */
+package org.apache.hc.client5.http.websocket.client.impl;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.java
new file mode 100644
index 0000000000..4ad2c5a513
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.java
@@ -0,0 +1,500 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.client.impl.protocol;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Base64;
+import java.util.List;
+import java.util.StringJoiner;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.websocket.api.WebSocket;
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.client5.http.websocket.client.impl.connector.WebSocketEndpointConnector;
+import org.apache.hc.client5.http.websocket.core.extension.ExtensionChain;
+import org.apache.hc.client5.http.websocket.core.extension.PerMessageDeflate;
+import org.apache.hc.client5.http.websocket.transport.WebSocketUpgrader;
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.URIScheme;
+import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester;
+import org.apache.hc.core5.http.message.BasicHttpRequest;
+import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler;
+import org.apache.hc.core5.http.nio.CapacityChannel;
+import org.apache.hc.core5.http.nio.DataStreamChannel;
+import org.apache.hc.core5.http.nio.RequestChannel;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.pool.ManagedConnPool;
+import org.apache.hc.core5.reactor.IOSession;
+import org.apache.hc.core5.reactor.ProtocolIOSession;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.Timeout;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * HTTP/1.1 Upgrade (RFC 6455). Uses getters on WebSocketClientConfig.
+ */
+@Internal
+public final class Http1UpgradeProtocol implements WebSocketProtocolStrategy {
+
+ private static final Logger LOG = LoggerFactory.getLogger(Http1UpgradeProtocol.class);
+
+ private final HttpAsyncRequester requester;
+ private final ManagedConnPool connPool;
+
+ public Http1UpgradeProtocol(final HttpAsyncRequester requester, final ManagedConnPool connPool) {
+ this.requester = requester;
+ this.connPool = connPool;
+ }
+
+ @Override
+ public CompletableFuture connect(
+ final URI uri,
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfg,
+ final HttpContext context) {
+
+ Args.notNull(uri, "uri");
+ Args.notNull(listener, "listener");
+ Args.notNull(cfg, "cfg");
+
+ final boolean secure = "wss".equalsIgnoreCase(uri.getScheme());
+ if (!secure && !"ws".equalsIgnoreCase(uri.getScheme())) {
+ final CompletableFuture f = new CompletableFuture<>();
+ f.completeExceptionally(new IllegalArgumentException("Scheme must be ws or wss"));
+ return f;
+ }
+
+ final String scheme = secure ? URIScheme.HTTPS.id : URIScheme.HTTP.id;
+ final int port = uri.getPort() > 0 ? uri.getPort() : secure ? 443 : 80;
+ final String host = Args.notBlank(uri.getHost(), "host");
+ String path = uri.getRawPath();
+ if (path == null || path.isEmpty()) {
+ path = "/";
+ }
+ final String fullPath = uri.getRawQuery() != null ? path + "?" + uri.getRawQuery() : path;
+ final HttpHost target = new HttpHost(scheme, host, port);
+
+ final CompletableFuture result = new CompletableFuture<>();
+ final WebSocketEndpointConnector wsRequester = new WebSocketEndpointConnector(requester, connPool);
+
+ final Timeout timeout = cfg.getConnectTimeout() != null ? cfg.getConnectTimeout() : Timeout.ofSeconds(10);
+
+ wsRequester.connect(target, timeout, null,
+ new FutureCallback() {
+ @Override
+ public void completed(final WebSocketEndpointConnector.ProtoEndpoint endpoint) {
+ try {
+ final String secKey = randomKey();
+ final BasicHttpRequest req = new BasicHttpRequest(HttpGet.METHOD_NAME, target, fullPath);
+
+ req.addHeader(HttpHeaders.CONNECTION, "Upgrade");
+ req.addHeader(HttpHeaders.UPGRADE, "websocket");
+ req.addHeader("Sec-WebSocket-Version", "13");
+ req.addHeader("Sec-WebSocket-Key", secKey);
+
+ // subprotocols
+ if (cfg.getSubprotocols() != null && !cfg.getSubprotocols().isEmpty()) {
+ final StringJoiner sj = new StringJoiner(", ");
+ for (final String p : cfg.getSubprotocols()) {
+ if (p != null && !p.isEmpty()) {
+ sj.add(p);
+ }
+ }
+ final String offered = sj.toString();
+ if (!offered.isEmpty()) {
+ req.addHeader("Sec-WebSocket-Protocol", offered);
+ }
+ }
+
+ // PMCE offer
+ if (cfg.isPerMessageDeflateEnabled()) {
+ final StringBuilder ext = new StringBuilder("permessage-deflate");
+ if (cfg.isOfferServerNoContextTakeover()) {
+ ext.append("; server_no_context_takeover");
+ }
+ if (cfg.isOfferClientNoContextTakeover()) {
+ ext.append("; client_no_context_takeover");
+ }
+ if (cfg.getOfferClientMaxWindowBits() != null) {
+ ext.append("; client_max_window_bits=").append(cfg.getOfferClientMaxWindowBits());
+ }
+ if (cfg.getOfferServerMaxWindowBits() != null) {
+ ext.append("; server_max_window_bits=").append(cfg.getOfferServerMaxWindowBits());
+ }
+ req.addHeader("Sec-WebSocket-Extensions", ext.toString());
+ }
+
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Dispatching HTTP/1.1 Upgrade: GET {} with headers:", fullPath);
+ for (final Header h : req.getHeaders()) {
+ LOG.debug(" {}: {}", h.getName(), h.getValue());
+ }
+ }
+
+ final AtomicBoolean done = new AtomicBoolean(false);
+
+ final AsyncClientExchangeHandler upgrade = new AsyncClientExchangeHandler() {
+ @Override
+ public void releaseResources() {
+ }
+
+ @Override
+ public void failed(final Exception cause) {
+ if (done.compareAndSet(false, true)) {
+ try {
+ endpoint.releaseAndDiscard();
+ } catch (final Throwable ignore) {
+ }
+ result.completeExceptionally(cause);
+ }
+ }
+
+ @Override
+ public void cancel() {
+ if (done.compareAndSet(false, true)) {
+ try {
+ endpoint.releaseAndDiscard();
+ } catch (final Throwable ignore) {
+ }
+ result.cancel(true);
+ }
+ }
+
+ @Override
+ public void produceRequest(final RequestChannel ch,
+ final HttpContext hc)
+ throws IOException, HttpException {
+ ch.sendRequest(req, null, hc);
+ }
+
+ @Override
+ public int available() {
+ return 0;
+ }
+
+ @Override
+ public void produce(final DataStreamChannel channel) {
+ }
+
+ @Override
+ public void updateCapacity(final CapacityChannel capacityChannel) {
+ }
+
+ @Override
+ public void consume(final ByteBuffer src) {
+ }
+
+ @Override
+ public void streamEnd(final List extends Header> trailers) {
+ }
+
+ @Override
+ public void consumeInformation(final HttpResponse response,
+ final HttpContext hc) {
+ final int code = response.getCode();
+ if (code == HttpStatus.SC_SWITCHING_PROTOCOLS && done.compareAndSet(false, true)) {
+ finishUpgrade(endpoint, response, secKey, listener, cfg, result);
+ }
+ }
+
+ @Override
+ public void consumeResponse(final HttpResponse response,
+ final EntityDetails entity,
+ final HttpContext hc) {
+ final int code = response.getCode();
+ if (code == HttpStatus.SC_SWITCHING_PROTOCOLS && done.compareAndSet(false, true)) {
+ finishUpgrade(endpoint, response, secKey, listener, cfg, result);
+ return;
+ }
+ failed(new IllegalStateException("Unexpected status: " + code));
+ }
+ };
+
+ endpoint.execute(upgrade, null, context);
+
+ } catch (final Exception ex) {
+ try {
+ endpoint.releaseAndDiscard();
+ } catch (final Throwable ignore) {
+ }
+ result.completeExceptionally(ex);
+ }
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ result.completeExceptionally(ex);
+ }
+
+ @Override
+ public void cancelled() {
+ result.cancel(true);
+ }
+ });
+
+ return result;
+ }
+
+ private void finishUpgrade(
+ final WebSocketEndpointConnector.ProtoEndpoint endpoint,
+ final HttpResponse response,
+ final String secKey,
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfg,
+ final CompletableFuture result) {
+ try {
+ final String accept = headerValue(response, "Sec-WebSocket-Accept");
+ final String expected = expectedAccept(secKey);
+ final String acceptValue = accept != null ? accept.trim() : null;
+ if (!expected.equals(acceptValue)) {
+ throw new IllegalStateException("Bad Sec-WebSocket-Accept");
+ }
+
+ final String upgrade = headerValue(response, "Upgrade");
+ if (upgrade == null || !"websocket".equalsIgnoreCase(upgrade.trim())) {
+ throw new IllegalStateException("Missing/invalid Upgrade header: " + upgrade);
+ }
+ if (!containsToken(response, "Connection", "Upgrade")) {
+ throw new IllegalStateException("Missing/invalid Connection header");
+ }
+
+ final String proto = headerValue(response, "Sec-WebSocket-Protocol");
+ if (proto != null && !proto.isEmpty()) {
+ boolean matched = false;
+ if (cfg.getSubprotocols() != null) {
+ for (final String p : cfg.getSubprotocols()) {
+ if (p.equals(proto)) {
+ matched = true;
+ break;
+ }
+ }
+ }
+ if (!matched) {
+ throw new IllegalStateException("Server selected subprotocol not offered: " + proto);
+ }
+ }
+
+ final ExtensionChain chain = buildExtensionChain(cfg, headerValue(response, "Sec-WebSocket-Extensions"));
+
+ final ProtocolIOSession ioSession = endpoint.getProtocolIOSession();
+ final WebSocketUpgrader upgrader = new WebSocketUpgrader(listener, cfg, chain, endpoint);
+ ioSession.registerProtocol("websocket", upgrader);
+ ioSession.switchProtocol("websocket", new FutureCallback() {
+ @Override
+ public void completed(final ProtocolIOSession s) {
+ s.setSocketTimeout(Timeout.DISABLED);
+ final WebSocket ws = upgrader.getWebSocket();
+ try {
+ listener.onOpen(ws);
+ } catch (final Throwable ignore) {
+ }
+ result.complete(ws);
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ try {
+ endpoint.releaseAndDiscard();
+ } catch (final Throwable ignore) {
+ }
+ result.completeExceptionally(ex);
+ }
+
+ @Override
+ public void cancelled() {
+ try {
+ endpoint.releaseAndDiscard();
+ } catch (final Throwable ignore) {
+ }
+ result.cancel(true);
+ }
+ });
+
+ } catch (final Exception ex) {
+ try {
+ endpoint.releaseAndDiscard();
+ } catch (final Throwable ignore) {
+ }
+ result.completeExceptionally(ex);
+ }
+ }
+
+ private static String headerValue(final HttpResponse r, final String name) {
+ final Header h = r.getFirstHeader(name);
+ return h != null ? h.getValue() : null;
+ }
+
+ private static boolean containsToken(final HttpResponse r, final String header, final String token) {
+ for (final Header h : r.getHeaders(header)) {
+ for (final String p : h.getValue().split(",")) {
+ if (p.trim().equalsIgnoreCase(token)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static String randomKey() {
+ final byte[] nonce = new byte[16];
+ ThreadLocalRandom.current().nextBytes(nonce);
+ return Base64.getEncoder().encodeToString(nonce);
+ }
+
+ private static String expectedAccept(final String key) throws Exception {
+ final MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
+ sha1.update((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").getBytes(StandardCharsets.US_ASCII));
+ return Base64.getEncoder().encodeToString(sha1.digest());
+ }
+
+ static ExtensionChain buildExtensionChain(final WebSocketClientConfig cfg, final String ext) {
+ final ExtensionChain chain = new ExtensionChain();
+ if (ext == null || ext.isEmpty()) {
+ return chain;
+ }
+ boolean pmceSeen = false, serverNoCtx = false, clientNoCtx = false;
+ Integer clientBits = null, serverBits = null;
+ final boolean offerServerNoCtx = cfg.isOfferServerNoContextTakeover();
+ final boolean offerClientNoCtx = cfg.isOfferClientNoContextTakeover();
+ final Integer offerClientBits = cfg.getOfferClientMaxWindowBits();
+ final Integer offerServerBits = cfg.getOfferServerMaxWindowBits();
+
+ final String[] tokens = ext.split(",");
+ for (final String raw0 : tokens) {
+ final String raw = raw0.trim();
+ final String[] parts = raw.split(";");
+ final String token = parts[0].trim().toLowerCase();
+
+ // Only permessage-deflate is supported
+ if (!"permessage-deflate".equals(token)) {
+ throw new IllegalStateException("Server selected unsupported extension: " + token);
+ }
+ if (pmceSeen) {
+ throw new IllegalStateException("Server selected permessage-deflate more than once");
+ }
+ pmceSeen = true;
+
+ for (int i = 1; i < parts.length; i++) {
+ final String p = parts[i].trim();
+ final int eq = p.indexOf('=');
+ if (eq < 0) {
+ if ("server_no_context_takeover".equalsIgnoreCase(p)) {
+ if (!offerServerNoCtx) {
+ throw new IllegalStateException("Server selected server_no_context_takeover not offered");
+ }
+ serverNoCtx = true;
+ } else if ("client_no_context_takeover".equalsIgnoreCase(p)) {
+ if (!offerClientNoCtx) {
+ throw new IllegalStateException("Server selected client_no_context_takeover not offered");
+ }
+ clientNoCtx = true;
+ } else {
+ throw new IllegalStateException("Unsupported permessage-deflate parameter: " + p);
+ }
+ } else {
+ final String k = p.substring(0, eq).trim();
+ String v = p.substring(eq + 1).trim();
+ if (v.length() >= 2 && v.charAt(0) == '"' && v.charAt(v.length() - 1) == '"') {
+ v = v.substring(1, v.length() - 1); // strip quotes if any
+ }
+ if ("client_max_window_bits".equalsIgnoreCase(k)) {
+ if (offerClientBits == null) {
+ throw new IllegalStateException("Server selected client_max_window_bits not offered");
+ }
+ try {
+ if (v.isEmpty()) {
+ throw new IllegalStateException("client_max_window_bits must have a value");
+ }
+ clientBits = Integer.parseInt(v);
+ if (clientBits < 8 || clientBits > 15) {
+ throw new IllegalStateException("client_max_window_bits out of range: " + clientBits);
+ }
+ } catch (final NumberFormatException nfe) {
+ throw new IllegalStateException("Invalid client_max_window_bits: " + v, nfe);
+ }
+ } else if ("server_max_window_bits".equalsIgnoreCase(k)) {
+ if (offerServerBits == null) {
+ throw new IllegalStateException("Server selected server_max_window_bits not offered");
+ }
+ try {
+ if (v.isEmpty()) {
+ throw new IllegalStateException("server_max_window_bits must have a value");
+ }
+ serverBits = Integer.parseInt(v);
+ if (serverBits < 8 || serverBits > 15) {
+ throw new IllegalStateException("server_max_window_bits out of range: " + serverBits);
+ }
+ } catch (final NumberFormatException nfe) {
+ throw new IllegalStateException("Invalid server_max_window_bits: " + v, nfe);
+ }
+ } else {
+ throw new IllegalStateException("Unsupported permessage-deflate parameter: " + k);
+ }
+ }
+ }
+ }
+
+ if (pmceSeen) {
+ if (!cfg.isPerMessageDeflateEnabled()) {
+ throw new IllegalStateException("Server negotiated PMCE but client disabled it");
+ }
+ if (clientBits != null) {
+ if (offerClientBits == null) {
+ throw new IllegalStateException("Server selected client_max_window_bits not offered");
+ }
+ if (!clientBits.equals(offerClientBits)) {
+ throw new IllegalStateException("Unsupported client_max_window_bits: " + clientBits
+ + " (offered " + offerClientBits + ")");
+ }
+ }
+ if (serverBits != null) {
+ if (offerServerBits != null && serverBits > offerServerBits) {
+ throw new IllegalStateException("server_max_window_bits exceeds offer: " + serverBits);
+ }
+ }
+ chain.add(new PerMessageDeflate(true, serverNoCtx, clientNoCtx, clientBits, serverBits));
+ }
+ return chain;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocol.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocol.java
new file mode 100644
index 0000000000..7b8907840b
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocol.java
@@ -0,0 +1,64 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.client.impl.protocol;
+
+import java.net.URI;
+import java.util.concurrent.CompletableFuture;
+
+import org.apache.hc.client5.http.websocket.api.WebSocket;
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.http.protocol.HttpContext;
+
+/**
+ * RFC 8441 (HTTP/2 Extended CONNECT) placeholder.
+ * No-args ctor (matches your build error). Falls back to H1.
+ */
+@Internal
+public final class Http2ExtendedConnectProtocol implements WebSocketProtocolStrategy {
+
+ public static final class H2NotAvailable extends RuntimeException {
+ public H2NotAvailable(final String msg) {
+ super(msg);
+ }
+ }
+
+ public Http2ExtendedConnectProtocol() {
+ }
+
+ @Override
+ public CompletableFuture connect(
+ final URI uri,
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfg,
+ final HttpContext context) {
+ final CompletableFuture f = new CompletableFuture<>();
+ f.completeExceptionally(new H2NotAvailable("HTTP/2 Extended CONNECT not wired yet"));
+ return f;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/WebSocketProtocolStrategy.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/WebSocketProtocolStrategy.java
new file mode 100644
index 0000000000..deebeddec1
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/WebSocketProtocolStrategy.java
@@ -0,0 +1,59 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.client.impl.protocol;
+
+import java.net.URI;
+import java.util.concurrent.CompletableFuture;
+
+import org.apache.hc.client5.http.websocket.api.WebSocket;
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.http.protocol.HttpContext;
+
+/**
+ * Minimal pluggable protocol strategy. One impl for H1 (RFC6455),
+ * one for H2 Extended CONNECT (RFC8441).
+ */
+@Internal
+public interface WebSocketProtocolStrategy {
+
+ /**
+ * Establish a WebSocket connection using a specific HTTP transport/protocol.
+ *
+ * @param uri ws:// or wss:// target
+ * @param listener user listener for WS events
+ * @param cfg client config (timeouts, subprotocols, PMCE offer, etc.)
+ * @param context optional HttpContext (may be {@code null})
+ * @return future completing with a connected {@link WebSocket} or exceptionally on failure
+ */
+ CompletableFuture connect(
+ URI uri,
+ WebSocketListener listener,
+ WebSocketClientConfig cfg,
+ HttpContext context);
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/package-info.java
new file mode 100644
index 0000000000..5820f61daa
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * Message-level helpers and codecs.
+ *
+ * Utilities for parsing and validating message semantics (e.g., CLOSE
+ * status code and reason handling).
+ *
+ * @since 5.7
+ */
+package org.apache.hc.client5.http.websocket.client.impl.protocol;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/package-info.java
new file mode 100644
index 0000000000..e650658364
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * High-level asynchronous WebSocket client.
+ *
+ * Provides {@code WebSocketClient}, which performs the HTTP/1.1 upgrade
+ * (RFC 6455) and exposes an application-level {@code WebSocket}.
+ *
+ * @since 5.7
+ */
+package org.apache.hc.client5.http.websocket.client;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/WebSocketProtocolException.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/WebSocketProtocolException.java
new file mode 100644
index 0000000000..c1af69a9a6
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/WebSocketProtocolException.java
@@ -0,0 +1,41 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.core.exceptions;
+
+
+import org.apache.hc.core5.annotation.Internal;
+
+@Internal
+public final class WebSocketProtocolException extends RuntimeException {
+
+ public final int closeCode;
+
+ public WebSocketProtocolException(final int closeCode, final String message) {
+ super(message);
+ this.closeCode = closeCode;
+ }
+}
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/package-info.java
new file mode 100644
index 0000000000..71bd5b4983
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * Message-level helpers and codecs.
+ *
+ * Utilities for parsing and validating message semantics (e.g., CLOSE
+ * status code and reason handling).
+ *
+ * @since 5.7
+ */
+package org.apache.hc.client5.http.websocket.core.exceptions;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChain.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChain.java
new file mode 100644
index 0000000000..69823a5b8a
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChain.java
@@ -0,0 +1,135 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.core.extension;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * Simple single-step chain; if multiple extensions are added they are applied in order.
+ * Only the FIRST extension can contribute the RSV bit (RSV1 in practice).
+ */
+@Internal
+public final class ExtensionChain {
+ private final List exts = new ArrayList<>();
+
+ public void add(final WebSocketExtensionChain e) {
+ if (e != null) {
+ exts.add(e);
+ }
+ }
+
+ public boolean isEmpty() {
+ return exts.isEmpty();
+ }
+
+ /**
+ * RSV bits used by the first extension in the chain (if any).
+ * Only the first extension may contribute RSV bits.
+ */
+ public int rsvMask() {
+ if (exts.isEmpty()) {
+ return 0;
+ }
+ return exts.get(0).rsvMask();
+ }
+
+ /**
+ * App-thread encoder chain.
+ */
+ public EncodeChain newEncodeChain() {
+ final List encs = new ArrayList<>(exts.size());
+ for (final WebSocketExtensionChain e : exts) {
+ encs.add(e.newEncoder());
+ }
+ return new EncodeChain(encs);
+ }
+
+ /**
+ * I/O-thread decoder chain.
+ */
+ public DecodeChain newDecodeChain() {
+ final List decs = new ArrayList<>(exts.size());
+ for (final WebSocketExtensionChain e : exts) {
+ decs.add(e.newDecoder());
+ }
+ return new DecodeChain(decs);
+ }
+
+ // ----------------------
+
+ public static final class EncodeChain {
+ private final List encs;
+
+ public EncodeChain(final List encs) {
+ this.encs = encs;
+ }
+
+ /**
+ * Encode one fragment through the chain; note RSV flag for the first extension.
+ * Returns {@link WebSocketExtensionChain.Encoded}.
+ */
+ public WebSocketExtensionChain.Encoded encode(final byte[] data, final boolean first, final boolean fin) {
+ if (encs.isEmpty()) {
+ return new WebSocketExtensionChain.Encoded(data, false);
+ }
+ byte[] out = data;
+ boolean setRsv1 = false;
+ boolean firstExt = true;
+ for (final WebSocketExtensionChain.Encoder e : encs) {
+ final WebSocketExtensionChain.Encoded res = e.encode(out, first, fin);
+ out = res.payload;
+ if (first && firstExt && res.setRsvOnFirst) {
+ setRsv1 = true;
+ }
+ firstExt = false;
+ }
+ return new WebSocketExtensionChain.Encoded(out, setRsv1);
+ }
+ }
+
+ public static final class DecodeChain {
+ private final List decs;
+
+ public DecodeChain(final List decs) {
+ this.decs = decs;
+ }
+
+ /**
+ * Decode a full message (reverse order if stacking).
+ */
+ public byte[] decode(final byte[] data) throws Exception {
+ byte[] out = data;
+ for (int i = decs.size() - 1; i >= 0; i--) {
+ out = decs.get(i).decode(out);
+ }
+ return out;
+ }
+ }
+}
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/PerMessageDeflate.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/PerMessageDeflate.java
new file mode 100644
index 0000000000..a8bef79ce7
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/PerMessageDeflate.java
@@ -0,0 +1,195 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.core.extension;
+
+import java.io.ByteArrayOutputStream;
+import java.util.zip.Deflater;
+import java.util.zip.Inflater;
+
+import org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits;
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * Client-side permessage-deflate (RFC 7692).
+ *
+ * Window bit parameters are negotiated during the handshake:
+ * {@code client_max_window_bits} limits the client's compression window (client->server),
+ * while {@code server_max_window_bits} limits the server's compression window (server->client).
+ * The decoder can accept any server window size (8..15). The encoder currently requires
+ * {@code client_max_window_bits} to be 15, due to JDK Deflater limitations.
+ */
+@Internal
+public final class PerMessageDeflate implements WebSocketExtensionChain {
+ private static final byte[] TAIL = new byte[]{0x00, 0x00, (byte) 0xFF, (byte) 0xFF};
+
+ private final boolean enabled;
+ private final boolean serverNoContextTakeover;
+ private final boolean clientNoContextTakeover;
+ private final Integer clientMaxWindowBits; // negotiated or null
+ private final Integer serverMaxWindowBits; // negotiated or null
+
+ public PerMessageDeflate(final boolean enabled,
+ final boolean serverNoContextTakeover,
+ final boolean clientNoContextTakeover,
+ final Integer clientMaxWindowBits,
+ final Integer serverMaxWindowBits) {
+ this.enabled = enabled;
+ this.serverNoContextTakeover = serverNoContextTakeover;
+ this.clientNoContextTakeover = clientNoContextTakeover;
+ this.clientMaxWindowBits = clientMaxWindowBits;
+ this.serverMaxWindowBits = serverMaxWindowBits;
+ }
+
+ @Override
+ public int rsvMask() {
+ return FrameHeaderBits.RSV1;
+ }
+
+ @Override
+ public Encoder newEncoder() {
+ if (!enabled) {
+ return (data, first, fin) -> new Encoded(data, false);
+ }
+ return new Encoder() {
+ private final Deflater def = new Deflater(Deflater.DEFAULT_COMPRESSION, true); // raw DEFLATE
+
+ @Override
+ public Encoded encode(final byte[] data, final boolean first, final boolean fin) {
+ final byte[] out = first && fin
+ ? compressMessage(data)
+ : compressFragment(data, fin);
+ // RSV1 on first compressed data frame only
+ return new Encoded(out, first);
+ }
+
+ private byte[] compressMessage(final byte[] data) {
+ return doDeflate(data, true, true, clientNoContextTakeover);
+ }
+
+ private byte[] compressFragment(final byte[] data, final boolean fin) {
+ return doDeflate(data, fin, true,fin && clientNoContextTakeover);
+ }
+
+ private byte[] doDeflate(final byte[] data,
+ final boolean fin,
+ final boolean stripTail,
+ final boolean maybeReset) {
+ if (data == null || data.length == 0) {
+ if (fin && maybeReset) {
+ def.reset();
+ }
+ return new byte[0];
+ }
+ def.setInput(data);
+ final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, data.length / 2));
+ final byte[] buf = new byte[8192];
+ while (!def.needsInput()) {
+ final int n = def.deflate(buf, 0, buf.length, Deflater.SYNC_FLUSH);
+ if (n > 0) {
+ out.write(buf, 0, n);
+ } else {
+ break;
+ }
+ }
+ byte[] all = out.toByteArray();
+ if (stripTail && all.length >= 4) {
+ final int newLen = all.length - 4; // strip 00 00 FF FF
+ if (newLen <= 0) {
+ all = new byte[0];
+ } else {
+ final byte[] trimmed = new byte[newLen];
+ System.arraycopy(all, 0, trimmed, 0, newLen);
+ all = trimmed;
+ }
+ }
+ if (fin && maybeReset) {
+ def.reset();
+ }
+ return all;
+ }
+ };
+ }
+
+ @Override
+ public Decoder newDecoder() {
+ if (!enabled) {
+ return payload -> payload;
+ }
+ return new Decoder() {
+ private final Inflater inf = new Inflater(true);
+
+ @Override
+ public byte[] decode(final byte[] compressedMessage) throws Exception {
+ final byte[] withTail;
+ if (compressedMessage == null || compressedMessage.length == 0) {
+ withTail = TAIL.clone();
+ } else {
+ withTail = new byte[compressedMessage.length + 4];
+ System.arraycopy(compressedMessage, 0, withTail, 0, compressedMessage.length);
+ System.arraycopy(TAIL, 0, withTail, compressedMessage.length, 4);
+ }
+
+ inf.setInput(withTail);
+ final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, withTail.length * 2));
+ final byte[] buf = new byte[8192];
+ while (!inf.needsInput()) {
+ final int n = inf.inflate(buf);
+ if (n > 0) {
+ out.write(buf, 0, n);
+ } else {
+ break;
+ }
+ }
+ if (serverNoContextTakeover) {
+ inf.reset();
+ }
+ return out.toByteArray();
+ }
+ };
+ }
+
+ // optional getters for logging/tests
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public boolean isServerNoContextTakeover() {
+ return serverNoContextTakeover;
+ }
+
+ public boolean isClientNoContextTakeover() {
+ return clientNoContextTakeover;
+ }
+
+ public Integer getClientMaxWindowBits() {
+ return clientMaxWindowBits;
+ }
+
+ public Integer getServerMaxWindowBits() {
+ return serverMaxWindowBits;
+ }
+}
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/WebSocketExtensionChain.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/WebSocketExtensionChain.java
new file mode 100644
index 0000000000..977014f18d
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/WebSocketExtensionChain.java
@@ -0,0 +1,80 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.core.extension;
+
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * Generic extension hook for payload transform (e.g., permessage-deflate).
+ * Implementations may return RSV mask (usually RSV1) and indicate whether
+ * the first frame of a message should set RSV.
+ */
+@Internal
+public interface WebSocketExtensionChain {
+
+ /**
+ * RSV bits this extension uses on the first data frame (e.g. 0x40 for RSV1).
+ */
+ int rsvMask();
+
+ /**
+ * Create a thread-confined encoder instance (app thread).
+ */
+ Encoder newEncoder();
+
+ /**
+ * Create a thread-confined decoder instance (I/O thread).
+ */
+ Decoder newDecoder();
+
+ /**
+ * Encoded fragment result.
+ */
+ final class Encoded {
+ public final byte[] payload;
+ public final boolean setRsvOnFirst;
+
+ public Encoded(final byte[] payload, final boolean setRsvOnFirst) {
+ this.payload = payload;
+ this.setRsvOnFirst = setRsvOnFirst;
+ }
+ }
+
+ interface Encoder {
+ /**
+ * Encode one fragment; return transformed payload and whether to set RSV on FIRST frame.
+ */
+ Encoded encode(byte[] data, boolean first, boolean fin);
+ }
+
+ interface Decoder {
+ /**
+ * Decode a full message produced with this extension.
+ */
+ byte[] decode(byte[] payload) throws Exception;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/package-info.java
new file mode 100644
index 0000000000..4ad28d0cf4
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * WebSocket extension SPI and implementations.
+ *
+ * Includes the generic {@code Extension} SPI, chaining support, and a
+ * client-side permessage-deflate (RFC 7692) implementation.
+ *
+ * @since 5.7
+ */
+package org.apache.hc.client5.http.websocket.core.extension;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameHeaderBits.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameHeaderBits.java
new file mode 100644
index 0000000000..9e76108f55
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameHeaderBits.java
@@ -0,0 +1,49 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.core.frame;
+
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * WebSocket frame header bit masks (RFC 6455 §5.2).
+ */
+@Internal
+public final class FrameHeaderBits {
+ private FrameHeaderBits() {
+ }
+
+ // First header byte
+ public static final int FIN = 0x80;
+ public static final int RSV1 = 0x40;
+ public static final int RSV2 = 0x20;
+ public static final int RSV3 = 0x10;
+ // low 4 bits (0x0F) are opcode
+
+ // Second header byte
+ public static final int MASK_BIT = 0x80; // client->server payload mask bit
+ // low 7 bits (0x7F) are payload len indicator
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameOpcode.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameOpcode.java
new file mode 100644
index 0000000000..524cffc33d
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameOpcode.java
@@ -0,0 +1,88 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.core.frame;
+
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * RFC 6455 opcode constants + helpers.
+ */
+@Internal
+public final class FrameOpcode {
+ public static final int CONT = 0x0;
+ public static final int TEXT = 0x1;
+ public static final int BINARY = 0x2;
+ public static final int CLOSE = 0x8;
+ public static final int PING = 0x9;
+ public static final int PONG = 0xA;
+
+ private FrameOpcode() {
+ }
+
+ /**
+ * Control frames have the high bit set in the low nibble (0x8–0xF).
+ */
+ public static boolean isControl(final int opcode) {
+ return (opcode & 0x08) != 0;
+ }
+
+ /**
+ * Data opcodes (not continuation).
+ */
+ public static boolean isData(final int opcode) {
+ return opcode == TEXT || opcode == BINARY;
+ }
+
+ /**
+ * Continuation opcode.
+ */
+ public static boolean isContinuation(final int opcode) {
+ return opcode == CONT;
+ }
+
+ /**
+ * Optional: human-readable name for debugging.
+ */
+ public static String name(final int opcode) {
+ switch (opcode) {
+ case CONT:
+ return "CONT";
+ case TEXT:
+ return "TEXT";
+ case BINARY:
+ return "BINARY";
+ case CLOSE:
+ return "CLOSE";
+ case PING:
+ return "PING";
+ case PONG:
+ return "PONG";
+ default:
+ return "0x" + Integer.toHexString(opcode);
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/WebSocketFrameWriter.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/WebSocketFrameWriter.java
new file mode 100644
index 0000000000..9992c67c08
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/WebSocketFrameWriter.java
@@ -0,0 +1,189 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.core.frame;
+
+import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.FIN;
+import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.MASK_BIT;
+import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.RSV1;
+import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.RSV2;
+import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.RSV3;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ThreadLocalRandom;
+
+import org.apache.hc.client5.http.websocket.core.message.CloseCodec;
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * RFC 6455 frame writer with helpers to build into an existing target buffer.
+ *
+ * @since 5.7
+ */
+@Internal
+public final class WebSocketFrameWriter {
+
+ // -- Text/Binary -----------------------------------------------------------
+
+ public ByteBuffer text(final CharSequence data, final boolean fin) {
+ final ByteBuffer payload = data == null ? ByteBuffer.allocate(0)
+ : StandardCharsets.UTF_8.encode(data.toString());
+ // Client → server MUST be masked
+ return frame(FrameOpcode.TEXT, payload, fin, true);
+ }
+
+ public ByteBuffer binary(final ByteBuffer data, final boolean fin) {
+ final ByteBuffer payload = data == null ? ByteBuffer.allocate(0) : data.asReadOnlyBuffer();
+ return frame(FrameOpcode.BINARY, payload, fin, true);
+ }
+
+ // -- Control frames (FIN=true, payload ≤ 125, never compressed) -----------
+
+ public ByteBuffer ping(final ByteBuffer payloadOrNull) {
+ final ByteBuffer p = payloadOrNull == null ? ByteBuffer.allocate(0) : payloadOrNull.asReadOnlyBuffer();
+ if (p.remaining() > 125) {
+ throw new IllegalArgumentException("PING payload > 125 bytes");
+ }
+ return frame(FrameOpcode.PING, p, true, true);
+ }
+
+ public ByteBuffer pong(final ByteBuffer payloadOrNull) {
+ final ByteBuffer p = payloadOrNull == null ? ByteBuffer.allocate(0) : payloadOrNull.asReadOnlyBuffer();
+ if (p.remaining() > 125) {
+ throw new IllegalArgumentException("PONG payload > 125 bytes");
+ }
+ return frame(FrameOpcode.PONG, p, true, true);
+ }
+
+ public ByteBuffer close(final int code, final String reason) {
+ if (!CloseCodec.isValidToSend(code)) {
+ throw new IllegalArgumentException("Invalid close code to send: " + code);
+ }
+ final String safeReason = CloseCodec.truncateReasonUtf8(reason);
+ final ByteBuffer reasonBuf = safeReason.isEmpty()
+ ? ByteBuffer.allocate(0)
+ : StandardCharsets.UTF_8.encode(safeReason);
+
+ if (reasonBuf.remaining() > 123) {
+ throw new IllegalArgumentException("Close reason too long (UTF-8 bytes > 123)");
+ }
+
+ final ByteBuffer p = ByteBuffer.allocate(2 + reasonBuf.remaining());
+ p.put((byte) (code >> 8 & 0xFF));
+ p.put((byte) (code & 0xFF));
+ if (reasonBuf.hasRemaining()) {
+ p.put(reasonBuf);
+ }
+ p.flip();
+ return frame(FrameOpcode.CLOSE, p, true, true);
+ }
+
+ public ByteBuffer closeEcho(final ByteBuffer payload) {
+ final ByteBuffer p = payload == null ? ByteBuffer.allocate(0) : payload.asReadOnlyBuffer();
+ if (p.remaining() > 125) {
+ throw new IllegalArgumentException("Close payload > 125 bytes");
+ }
+ return frame(FrameOpcode.CLOSE, p, true, true);
+ }
+
+ // -- Core framing ----------------------------------------------------------
+
+ public ByteBuffer frame(final int opcode, final ByteBuffer payload, final boolean fin, final boolean mask) {
+ return frameWithRSV(opcode, payload, fin, mask, 0);
+ }
+
+ public ByteBuffer frameWithRSV(final int opcode, final ByteBuffer payload, final boolean fin,
+ final boolean mask, final int rsvBits) {
+ final int len = payload == null ? 0 : payload.remaining();
+ final int hdrExtra = len <= 125 ? 0 : len <= 0xFFFF ? 2 : 8;
+ final int maskLen = mask ? 4 : 0;
+ final ByteBuffer out = ByteBuffer.allocate(2 + hdrExtra + maskLen + len).order(ByteOrder.BIG_ENDIAN);
+ frameIntoWithRSV(opcode, payload, fin, mask, rsvBits, out);
+ out.flip();
+ return out;
+ }
+
+ public ByteBuffer frameInto(final int opcode, final ByteBuffer payload, final boolean fin,
+ final boolean mask, final ByteBuffer out) {
+ return frameIntoWithRSV(opcode, payload, fin, mask, 0, out);
+ }
+
+ public ByteBuffer frameIntoWithRSV(final int opcode, final ByteBuffer payload, final boolean fin,
+ final boolean mask, final int rsvBits, final ByteBuffer out) {
+ final int len = payload == null ? 0 : payload.remaining();
+
+ if (FrameOpcode.isControl(opcode)) {
+ if (!fin) {
+ throw new IllegalArgumentException("Control frames must not be fragmented (FIN=false)");
+ }
+ if (len > 125) {
+ throw new IllegalArgumentException("Control frame payload > 125 bytes");
+ }
+ if ((rsvBits & (RSV1 | RSV2 | RSV3)) != 0) {
+ throw new IllegalArgumentException("RSV bits must be 0 on control frames");
+ }
+ }
+
+ final int finBit = fin ? FIN : 0;
+ out.put((byte) (finBit | rsvBits & (RSV1 | RSV2 | RSV3) | opcode & 0x0F));
+
+ if (len <= 125) {
+ out.put((byte) ((mask ? MASK_BIT : 0) | len));
+ } else if (len <= 0xFFFF) {
+ out.put((byte) ((mask ? MASK_BIT : 0) | 126));
+ out.putShort((short) len);
+ } else {
+ out.put((byte) ((mask ? MASK_BIT : 0) | 127));
+ out.putLong(len & 0x7FFF_FFFF_FFFF_FFFFL);
+ }
+
+ int[] mkey = null;
+ if (mask) {
+ mkey = new int[]{rnd(), rnd(), rnd(), rnd()};
+ out.put((byte) mkey[0]).put((byte) mkey[1]).put((byte) mkey[2]).put((byte) mkey[3]);
+ }
+
+ if (len > 0) {
+ final ByteBuffer src = payload.asReadOnlyBuffer();
+ int i = 0; // simpler, safer mask index
+ while (src.hasRemaining()) {
+ int b = src.get() & 0xFF;
+ if (mask) {
+ b ^= mkey[i & 3];
+ i++;
+ }
+ out.put((byte) b);
+ }
+ }
+ return out;
+ }
+
+ private static int rnd() {
+ return ThreadLocalRandom.current().nextInt(256);
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/package-info.java
new file mode 100644
index 0000000000..dcf358cf08
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * Low-level WebSocket frame helpers.
+ *
+ * Opcode constants, header bit masks, and frame writer utilities aligned
+ * with RFC 6455.
+ *
+ * @since 5.7
+ */
+package org.apache.hc.client5.http.websocket.core.frame;
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/CloseCodec.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/CloseCodec.java
new file mode 100644
index 0000000000..d6e8951121
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/CloseCodec.java
@@ -0,0 +1,190 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.core.message;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * Helpers for RFC6455 CLOSE parsing & validation.
+ */
+@Internal
+public final class CloseCodec {
+
+ private CloseCodec() {
+ }
+
+
+ /**
+ * Reads the close status code from the payload buffer, if present.
+ * Returns {@code 1005} (“no status code present”) when the payload
+ * does not contain at least two bytes.
+ */
+ public static int readCloseCode(final ByteBuffer payloadRO) {
+ if (payloadRO == null || payloadRO.remaining() < 2) {
+ return 1005; // “no status code present”
+ }
+ final int b1 = payloadRO.get() & 0xFF;
+ final int b2 = payloadRO.get() & 0xFF;
+ return (b1 << 8) | b2;
+ }
+
+ /**
+ * Reads the close reason from the remaining bytes of the payload
+ * as UTF-8. Returns an empty string if there is no payload left.
+ */
+ public static String readCloseReason(final ByteBuffer payloadRO) {
+ if (payloadRO == null || !payloadRO.hasRemaining()) {
+ return "";
+ }
+ final ByteBuffer dup = payloadRO.slice();
+ return StandardCharsets.UTF_8.decode(dup).toString();
+ }
+
+ // ---- RFC validation (sender & receiver) ---------------------------------
+
+ /**
+ * RFC 6455 §7.4.2: MUST NOT appear on the wire.
+ */
+ private static boolean isForbiddenOnWire(final int code) {
+ return code == 1005 || code == 1006 || code == 1015;
+ }
+
+ /**
+ * Codes defined by RFC 6455 to send (and likewise valid to receive).
+ */
+ private static boolean isRfcDefined(final int code) {
+ switch (code) {
+ case 1000: // normal
+ case 1001: // going away
+ case 1002: // protocol error
+ case 1003: // unsupported data
+ case 1007: // invalid payload data
+ case 1008: // policy violation
+ case 1009: // message too big
+ case 1010: // mandatory extension
+ case 1011: // internal error
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Application/reserved range that may be sent by endpoints.
+ */
+ private static boolean isAppRange(final int code) {
+ return code >= 3000 && code <= 4999;
+ }
+
+ /**
+ * Validate a code we intend to PUT ON THE WIRE (sender-side).
+ */
+ public static boolean isValidToSend(final int code) {
+ if (code < 0) {
+ return false;
+ }
+ if (isForbiddenOnWire(code)) {
+ return false;
+ }
+ return isRfcDefined(code) || isAppRange(code);
+ }
+
+ /**
+ * Validate a code we PARSED FROM THE WIRE (receiver-side).
+ */
+ public static boolean isValidToReceive(final int code) {
+ // 1005, 1006, 1015 must not appear on the wire
+ if (isForbiddenOnWire(code)) {
+ return false;
+ }
+ // Same allowed sets otherwise
+ return isRfcDefined(code) || isAppRange(code);
+ }
+
+ // ---- Reason handling: max 123 bytes (2 bytes used by code) --------------
+
+ /**
+ * Returns a UTF-8 string truncated to ≤ 123 bytes, preserving code-points.
+ * This ensures that a CLOSE frame payload (2-byte status code + reason)
+ * never exceeds the 125-byte control frame limit.
+ */
+ public static String truncateReasonUtf8(final String reason) {
+ if (reason == null || reason.isEmpty()) {
+ return "";
+ }
+ final byte[] bytes = reason.getBytes(StandardCharsets.UTF_8);
+ if (bytes.length <= 123) {
+ return reason;
+ }
+ int i = 0;
+ int byteCount = 0;
+ while (i < reason.length()) {
+ final int cp = reason.codePointAt(i);
+ final int charCount = Character.charCount(cp);
+ final int extra = new String(Character.toChars(cp))
+ .getBytes(StandardCharsets.UTF_8).length;
+ if (byteCount + extra > 123) {
+ break;
+ }
+ byteCount += extra;
+ i += charCount;
+ }
+ return reason.substring(0, i);
+ }
+
+ // ---- Encoding -----------------------------------------------------------
+
+ /**
+ * Encodes a close status code and reason into a payload suitable for a
+ * CLOSE control frame:
+ *
+ *
+ * payload[0] = high-byte of status code
+ * payload[1] = low-byte of status code
+ * payload[2..] = UTF-8 bytes of the (possibly truncated) reason
+ *
+ *
+ * The reason is internally truncated to ≤ 123 UTF-8 bytes to ensure the
+ * resulting payload never exceeds the 125-byte control frame limit.
+ *
+ * The caller is expected to have already validated the status code with
+ * {@link #isValidToSend(int)}.
+ */
+ public static byte[] encode(final int statusCode, final String reason) {
+ final String truncated = truncateReasonUtf8(reason);
+ final byte[] reasonBytes = truncated.getBytes(StandardCharsets.UTF_8);
+ // 2 bytes for the status code
+ final byte[] payload = new byte[2 + reasonBytes.length];
+ payload[0] = (byte) ((statusCode >>> 8) & 0xFF);
+ payload[1] = (byte) (statusCode & 0xFF);
+ System.arraycopy(reasonBytes, 0, payload, 2, reasonBytes.length);
+ return payload;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/package-info.java
new file mode 100644
index 0000000000..475f42c2e6
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * Message-level helpers and codecs.
+ *
+ *
Utilities for parsing and validating message semantics (e.g., CLOSE
+ * status code and reason handling).
+ *
+ * @since 5.7
+ */
+package org.apache.hc.client5.http.websocket.core.message;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/package-info.java
new file mode 100644
index 0000000000..2860c787cd
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * Core WebSocket implementation utilities.
+ *
+ * Implementation detail packages live under {@code core}. These are not
+ * part of the public API and may change without notice.
+ *
+ * @since 5.7
+ */
+package org.apache.hc.client5.http.websocket.core;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/ByteBufferPool.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/ByteBufferPool.java
new file mode 100644
index 0000000000..4c8a9281a2
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/ByteBufferPool.java
@@ -0,0 +1,127 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.core.util;
+
+import java.nio.ByteBuffer;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * Lock-free fixed-size ByteBuffer pool with a hard capacity limit.
+ * Buffers are cleared before reuse. Non-matching capacities are dropped.
+ *
+ * @since 5.7
+ */
+@Internal
+public final class ByteBufferPool {
+
+ private final ConcurrentLinkedQueue pool = new ConcurrentLinkedQueue<>();
+ private final AtomicInteger pooled = new AtomicInteger(0);
+
+ private final int bufferSize;
+ private final int maxCapacity;
+ private final boolean direct;
+
+ public ByteBufferPool(final int bufferSize, final int maxCapacity) {
+ this(bufferSize, maxCapacity, false);
+ }
+
+ public ByteBufferPool(final int bufferSize, final int maxCapacity, final boolean direct) {
+ if (bufferSize <= 0 || maxCapacity < 0) {
+ throw new IllegalArgumentException("Invalid pool configuration");
+ }
+ this.bufferSize = bufferSize;
+ this.maxCapacity = maxCapacity;
+ this.direct = direct;
+ }
+
+ /**
+ * Acquire a buffer or allocate a new one if the pool is empty.
+ */
+ public ByteBuffer acquire() {
+ final ByteBuffer buf = pool.poll();
+ if (buf != null) {
+ pooled.decrementAndGet();
+ buf.clear();
+ return buf;
+ }
+ return direct ? ByteBuffer.allocateDirect(bufferSize) : ByteBuffer.allocate(bufferSize);
+ }
+
+ /**
+ * Return a buffer to the pool iff it matches the configured capacity and there is room.
+ */
+ public void release(final ByteBuffer buffer) {
+ if (buffer == null || buffer.capacity() != bufferSize) {
+ return;
+ }
+ buffer.clear();
+ for (;;) {
+ final int n = pooled.get();
+ if (n >= maxCapacity) {
+ return;
+ }
+ if (pooled.compareAndSet(n, n + 1)) {
+ pool.offer(buffer);
+ return;
+ }
+ }
+ }
+
+ /**
+ * Drain the pool.
+ */
+ public void clear() {
+ while (pool.poll() != null) { /* drain */ }
+ pooled.set(0);
+ }
+
+ /**
+ * Size in bytes of pooled buffers.
+ */
+ public int bufferSize() {
+ return bufferSize;
+ }
+
+ /**
+ * Backwards-compatible accessor for callers expecting getBufferSize().
+ */
+ public int getBufferSize() {
+ return bufferSize;
+ }
+
+ public int maxCapacity() {
+ return maxCapacity;
+ }
+
+ public int pooledCount() {
+ return pooled.get();
+ }
+
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/package-info.java
new file mode 100644
index 0000000000..168ca362e4
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * Message-level helpers and codecs.
+ *
+ * Utilities for parsing and validating message semantics (e.g., CLOSE
+ * status code and reason handling).
+ *
+ * @since 5.7
+ */
+package org.apache.hc.client5.http.websocket.core.util;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/package-info.java
new file mode 100644
index 0000000000..70b0f5705a
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/package-info.java
@@ -0,0 +1,74 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * Client-side WebSocket support built on top of Apache HttpClient.
+ *
+ * This package provides the public API for establishing and using
+ * WebSocket connections according to RFC 6455. WebSocket sessions
+ * are created by upgrading an HTTP request and are backed internally
+ * by the non-blocking I/O reactor used by the HttpClient async APIs.
+ *
+ * Core abstractions
+ *
+ * - {@link org.apache.hc.client5.http.websocket.api.WebSocket WebSocket} –
+ * application view of a single WebSocket connection, used to send
+ * text and binary messages and initiate the close handshake.
+ * - {@link org.apache.hc.client5.http.websocket.api.WebSocketListener WebSocketListener} –
+ * callback interface that receives inbound messages, pings, pongs,
+ * errors, and close notifications.
+ * - {@link org.apache.hc.client5.http.websocket.api.WebSocketClientConfig WebSocketClientConfig} –
+ * immutable configuration for timeouts, maximum frame and message
+ * sizes, auto-pong behaviour, and buffer management.
+ * - {@link org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient CloseableWebSocketClient} –
+ * high-level client for establishing WebSocket connections.
+ * - {@link org.apache.hc.client5.http.websocket.client.WebSocketClients WebSocketClients} and
+ * {@link org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder WebSocketClientBuilder} –
+ * factory and builder for creating and configuring WebSocket clients.
+ *
+ *
+ * Threading model
+ * Outbound operations on {@code WebSocket} are thread-safe and may be
+ * invoked from arbitrary application threads. Inbound callbacks on
+ * {@code WebSocketListener} are normally executed on I/O dispatcher
+ * threads; listeners should avoid long blocking operations.
+ *
+ * Close handshake
+ * The implementation follows the close handshake defined in RFC 6455.
+ * Applications should initiate shutdown via
+ * {@link org.apache.hc.client5.http.websocket.api.WebSocket#close(int, String)}
+ * and treat receipt of a close frame as a terminal event. The configured
+ * {@code closeWaitTimeout} controls how long the client will wait for the
+ * peer's close frame before the underlying connection is closed.
+ *
+ * Classes in {@code org.apache.hc.client5.http.websocket.core} and
+ * {@code org.apache.hc.client5.http.websocket.transport} are internal
+ * implementation details and are not intended for direct use.
+ *
+ * @since 5.7
+ */
+package org.apache.hc.client5.http.websocket;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketFrameDecoder.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketFrameDecoder.java
new file mode 100644
index 0000000000..3153ed5bab
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketFrameDecoder.java
@@ -0,0 +1,172 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.transport;
+
+import java.nio.ByteBuffer;
+
+import org.apache.hc.client5.http.websocket.core.exceptions.WebSocketProtocolException;
+import org.apache.hc.client5.http.websocket.core.frame.FrameOpcode;
+import org.apache.hc.core5.annotation.Internal;
+
+@Internal
+public final class WebSocketFrameDecoder {
+ private final int maxFrameSize;
+ private final boolean strictNoExtensions;
+
+ private int opcode;
+ private boolean fin;
+ private boolean rsv1, rsv2, rsv3;
+ private ByteBuffer payload = ByteBuffer.allocate(0);
+ private final boolean expectMasked;
+
+
+
+ public WebSocketFrameDecoder(final int maxFrameSize, final boolean strictNoExtensions) {
+ this(maxFrameSize, strictNoExtensions, false);
+ }
+
+ public WebSocketFrameDecoder(final int maxFrameSize) {
+ this(maxFrameSize, true, false);
+ }
+
+ public WebSocketFrameDecoder(final int maxFrameSize,
+ final boolean strictNoExtensions,
+ final boolean expectMasked) {
+ this.maxFrameSize = maxFrameSize;
+ this.strictNoExtensions = strictNoExtensions;
+ this.expectMasked = expectMasked;
+ }
+
+ public boolean decode(final ByteBuffer in) {
+ in.mark();
+ if (in.remaining() < 2) {
+ in.reset();
+ return false;
+ }
+
+ final int b0 = in.get() & 0xFF;
+ final int b1 = in.get() & 0xFF;
+
+ fin = (b0 & 0x80) != 0;
+ rsv1 = (b0 & 0x40) != 0;
+ rsv2 = (b0 & 0x20) != 0;
+ rsv3 = (b0 & 0x10) != 0;
+
+ if (strictNoExtensions && (rsv1 || rsv2 || rsv3)) {
+ throw new WebSocketProtocolException(1002, "RSV bits set without extension");
+ }
+
+ opcode = b0 & 0x0F;
+
+ if (opcode != 0 && opcode != 1 && opcode != 2 && opcode != 8 && opcode != 9 && opcode != 10) {
+ throw new WebSocketProtocolException(1002, "Reserved/unknown opcode: " + opcode);
+ }
+
+ final boolean masked = (b1 & 0x80) != 0;
+ long len = b1 & 0x7F;
+
+ // Mode-aware masking rule
+ if (masked != expectMasked) {
+ if (expectMasked) {
+ // server decoding client frames: clients MUST mask
+ throw new WebSocketProtocolException(1002, "Client frame is not masked");
+ } else {
+ // client decoding server frames: servers MUST NOT mask
+ throw new WebSocketProtocolException(1002, "Server frame is masked");
+ }
+ }
+
+ if (len == 126) {
+ if (in.remaining() < 2) {
+ in.reset();
+ return false;
+ }
+ len = in.getShort() & 0xFFFF;
+ } else if (len == 127) {
+ if (in.remaining() < 8) {
+ in.reset();
+ return false;
+ }
+ final long l = in.getLong();
+ if (l < 0) {
+ throw new WebSocketProtocolException(1002, "Negative length");
+ }
+ len = l;
+ }
+
+ if (FrameOpcode.isControl(opcode)) {
+ if (!fin) {
+ throw new WebSocketProtocolException(1002, "fragmented control frame");
+ }
+ if (len > 125) {
+ throw new WebSocketProtocolException(1002, "control frame too large");
+ }
+ // (RSV checks above already cover RSV!=0)
+ }
+
+ if (len > Integer.MAX_VALUE || maxFrameSize > 0 && len > maxFrameSize) {
+ throw new WebSocketProtocolException(1009, "Frame too large: " + len);
+ }
+
+ if (in.remaining() < len) {
+ in.reset();
+ return false;
+ }
+
+ final ByteBuffer data = ByteBuffer.allocate((int) len);
+ for (int i = 0; i < len; i++) {
+ data.put(in.get());
+ }
+ data.flip();
+ payload = data.asReadOnlyBuffer();
+ return true;
+ }
+
+ public int opcode() {
+ return opcode;
+ }
+
+ public boolean fin() {
+ return fin;
+ }
+
+ public boolean rsv1() {
+ return rsv1;
+ }
+
+ public boolean rsv2() {
+ return rsv2;
+ }
+
+ public boolean rsv3() {
+ return rsv3;
+ }
+
+ public ByteBuffer payload() {
+ return payload.asReadOnlyBuffer();
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java
new file mode 100644
index 0000000000..12405a1266
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java
@@ -0,0 +1,449 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.transport;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeoutException;
+
+import org.apache.hc.client5.http.websocket.core.exceptions.WebSocketProtocolException;
+import org.apache.hc.client5.http.websocket.core.frame.FrameOpcode;
+import org.apache.hc.client5.http.websocket.core.message.CloseCodec;
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.reactor.EventMask;
+import org.apache.hc.core5.reactor.IOSession;
+import org.apache.hc.core5.util.Timeout;
+
+/**
+ * Inbound path: decoding, validation, fragment assembly, close handshake.
+ */
+@Internal
+final class WebSocketInbound {
+
+ private final WebSocketSessionState s;
+ private final WebSocketOutbound out;
+
+ WebSocketInbound(final WebSocketSessionState state, final WebSocketOutbound outbound) {
+ this.s = state;
+ this.out = outbound;
+ }
+
+ // ---- lifecycle ----
+ void onConnected(final IOSession ioSession) {
+ ioSession.setSocketTimeout(Timeout.DISABLED);
+ ioSession.setEventMask(EventMask.READ | EventMask.WRITE);
+ }
+
+ void onTimeout(final IOSession ioSession, final Timeout timeout) {
+ try {
+ final String msg = "I/O timeout: " + (timeout != null ? timeout : Timeout.ZERO_MILLISECONDS);
+ s.listener.onError(new TimeoutException(msg));
+ } catch (final Throwable ignore) {
+ }
+ }
+
+ void onException(final IOSession ioSession, final Exception cause) {
+ try {
+ s.listener.onError(cause);
+ } catch (final Throwable ignore) {
+ }
+ }
+
+ void onDisconnected(final IOSession ioSession) {
+ if (s.open.getAndSet(false)) {
+ try {
+ s.listener.onClose(1006, "abnormal closure");
+ } catch (final Throwable ignore) {
+ }
+ }
+ if (s.readBuf != null) {
+ s.bufferPool.release(s.readBuf);
+ s.readBuf = null;
+ }
+ out.drainAndRelease();
+ ioSession.clearEvent(EventMask.READ | EventMask.WRITE);
+ }
+
+ void onInputReady(final IOSession ioSession, final ByteBuffer src) {
+ try {
+ if (!s.open.get() && !s.closeSent.get()) {
+ return;
+ }
+
+ if (s.readBuf == null) {
+ s.readBuf = s.bufferPool.acquire();
+ if (s.readBuf == null) {
+ return;
+ }
+ }
+
+ if (src != null && src.hasRemaining()) {
+ appendToInbuf(src);
+ }
+
+ int n;
+ do {
+ ByteBuffer rb = s.readBuf;
+ if (rb == null) {
+ rb = s.bufferPool.acquire();
+ if (rb == null) {
+ return;
+ }
+ s.readBuf = rb;
+ }
+ rb.clear();
+ n = ioSession.read(rb);
+ if (n > 0) {
+ rb.flip();
+ appendToInbuf(rb);
+ }
+ } while (n > 0);
+
+ if (n < 0) {
+ onDisconnected(ioSession);
+ return;
+ }
+
+ s.inbuf.flip();
+ for (; ; ) {
+ final boolean has;
+ try {
+ has = s.decoder.decode(s.inbuf);
+ } catch (final RuntimeException rte) {
+ final int code = rte instanceof WebSocketProtocolException
+ ? ((WebSocketProtocolException) rte).closeCode
+ : 1002;
+ initiateCloseAndWait(ioSession, code, rte.getMessage());
+ s.inbuf.clear();
+ return;
+ }
+ if (!has) {
+ break;
+ }
+
+ final int op = s.decoder.opcode();
+ final boolean fin = s.decoder.fin();
+ final boolean r1 = s.decoder.rsv1();
+ final boolean r2 = s.decoder.rsv2();
+ final boolean r3 = s.decoder.rsv3();
+ final ByteBuffer payload = s.decoder.payload();
+
+ if (r2 || r3) {
+ initiateCloseAndWait(ioSession, 1002, "RSV2/RSV3 not supported");
+ s.inbuf.clear();
+ return;
+ }
+ if (r1 && s.decChain == null) {
+ initiateCloseAndWait(ioSession, 1002, "RSV1 without negotiated extension");
+ s.inbuf.clear();
+ return;
+ }
+
+ if (s.closeSent.get() && op != FrameOpcode.CLOSE) {
+ continue;
+ }
+
+ if (FrameOpcode.isControl(op)) {
+ if (!fin) {
+ initiateCloseAndWait(ioSession, 1002, "fragmented control frame");
+ s.inbuf.clear();
+ return;
+ }
+ if (payload.remaining() > 125) {
+ initiateCloseAndWait(ioSession, 1002, "control frame too large");
+ s.inbuf.clear();
+ return;
+ }
+ }
+
+ switch (op) {
+ case FrameOpcode.PING: {
+ try {
+ s.listener.onPing(payload.asReadOnlyBuffer());
+ } catch (final Throwable ignore) {
+ }
+ if (s.cfg.isAutoPong()) {
+ out.enqueueCtrl(out.pooledFrame(FrameOpcode.PONG, payload.asReadOnlyBuffer(), true));
+ }
+ break;
+ }
+ case FrameOpcode.PONG: {
+ try {
+ s.listener.onPong(payload.asReadOnlyBuffer());
+ } catch (final Throwable ignore) {
+ }
+ break;
+ }
+ case FrameOpcode.CLOSE: {
+ final ByteBuffer ro = payload.asReadOnlyBuffer();
+ int code = 1005;
+ String reason = "";
+ final int len = ro.remaining();
+
+ if (len == 1) {
+ initiateCloseAndWait(ioSession, 1002, "Close frame length of 1 is invalid");
+ s.inbuf.clear();
+ return;
+ } else if (len >= 2) {
+ final ByteBuffer dup = ro.slice();
+ code = CloseCodec.readCloseCode(dup);
+
+ if (!CloseCodec.isValidToReceive(code)) {
+ initiateCloseAndWait(ioSession, 1002, "Invalid close code: " + code);
+ s.inbuf.clear();
+ return;
+ }
+
+ if (dup.hasRemaining()) {
+ final CharsetDecoder dec = StandardCharsets.UTF_8
+ .newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT);
+ try {
+ reason = dec.decode(dup.asReadOnlyBuffer()).toString();
+ } catch (final CharacterCodingException badUtf8) {
+ initiateCloseAndWait(ioSession, 1007, "Invalid UTF-8 in close reason");
+ s.inbuf.clear();
+ return;
+ }
+ }
+ }
+
+ notifyCloseOnce(code, reason);
+
+ s.closeReceived.set(true);
+
+ if (!s.closeSent.get()) {
+ out.enqueueCtrl(out.pooledCloseEcho(ro));
+ }
+
+ s.session.setSocketTimeout(s.cfg.getCloseWaitTimeout());
+ s.closeAfterFlush = true;
+ ioSession.clearEvent(EventMask.READ);
+ ioSession.setEvent(EventMask.WRITE);
+ s.inbuf.clear();
+ return;
+ }
+ case FrameOpcode.CONT: {
+ if (s.assemblingOpcode == -1) {
+ initiateCloseAndWait(ioSession, 1002, "Unexpected continuation frame");
+ s.inbuf.clear();
+ return;
+ }
+ if (r1) {
+ initiateCloseAndWait(ioSession, 1002, "RSV1 set on continuation");
+ s.inbuf.clear();
+ return;
+ }
+ appendToMessage(payload, ioSession);
+ if (fin) {
+ deliverAssembledMessage();
+ }
+ break;
+ }
+ case FrameOpcode.TEXT:
+ case FrameOpcode.BINARY: {
+ if (s.assemblingOpcode != -1) {
+ initiateCloseAndWait(ioSession, 1002, "New data frame while fragmented message in progress");
+ s.inbuf.clear();
+ return;
+ }
+ if (!fin) {
+ startMessage(op, payload, r1, ioSession);
+ break;
+ }
+ if (s.cfg.getMaxMessageSize() > 0 && payload.remaining() > s.cfg.getMaxMessageSize()) {
+ initiateCloseAndWait(ioSession, 1009, "Message too big");
+ break;
+ }
+ if (r1 && s.decChain != null) {
+ final byte[] comp = toBytes(payload);
+ final byte[] plain;
+ try {
+ plain = s.decChain.decode(comp);
+ } catch (final Exception e) {
+ initiateCloseAndWait(ioSession, 1007, "Extension decode failed");
+ s.inbuf.clear();
+ return;
+ }
+ deliverSingle(op, ByteBuffer.wrap(plain));
+ } else {
+ deliverSingle(op, payload.asReadOnlyBuffer());
+ }
+ break;
+ }
+ default: {
+ initiateCloseAndWait(ioSession, 1002, "Unsupported opcode: " + op);
+ s.inbuf.clear();
+ return;
+ }
+ }
+ }
+ s.inbuf.compact();
+ } catch (final Exception ex) {
+ onException(ioSession, ex);
+ ioSession.close(CloseMode.GRACEFUL);
+ }
+ }
+
+ private void appendToInbuf(final ByteBuffer src) {
+ if (src == null || !src.hasRemaining()) {
+ return;
+ }
+ if (s.inbuf.remaining() < src.remaining()) {
+ final int need = s.inbuf.position() + src.remaining();
+ final int newCap = Math.max(s.inbuf.capacity() * 2, need);
+ final ByteBuffer bigger = ByteBuffer.allocate(newCap);
+ s.inbuf.flip();
+ bigger.put(s.inbuf);
+ s.inbuf = bigger;
+ }
+ s.inbuf.put(src);
+ }
+
+ private void startMessage(final int opcode, final ByteBuffer payload, final boolean rsv1, final IOSession ioSession) {
+ s.assemblingOpcode = opcode;
+ s.assemblingCompressed = rsv1 && s.decChain != null;
+ s.assemblingBytes = new ByteArrayOutputStream(Math.max(1024, payload.remaining()));
+ s.assemblingSize = 0L;
+ appendToMessage(payload, ioSession);
+ }
+
+ private void appendToMessage(final ByteBuffer payload, final IOSession ioSession) {
+ final ByteBuffer dup = payload.asReadOnlyBuffer();
+ final int n = dup.remaining();
+ s.assemblingSize += n;
+ if (s.cfg.getMaxMessageSize() > 0 && s.assemblingSize > s.cfg.getMaxMessageSize()) {
+ initiateCloseAndWait(ioSession, 1009, "Message too big");
+ return;
+ }
+ final byte[] tmp = new byte[n];
+ dup.get(tmp);
+ s.assemblingBytes.write(tmp, 0, n);
+ }
+
+ private void deliverAssembledMessage() {
+ final byte[] body = s.assemblingBytes.toByteArray();
+ final int op = s.assemblingOpcode;
+ final boolean compressed = s.assemblingCompressed;
+
+ s.assemblingOpcode = -1;
+ s.assemblingCompressed = false;
+ s.assemblingBytes = null;
+ s.assemblingSize = 0L;
+
+ byte[] data = body;
+ if (compressed && s.decChain != null) {
+ try {
+ data = s.decChain.decode(body);
+ } catch (final Exception e) {
+ try {
+ s.listener.onError(e);
+ } catch (final Throwable ignore) {
+ }
+ return;
+ }
+ }
+
+ if (op == FrameOpcode.TEXT) {
+ final CharsetDecoder dec = StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT);
+ try {
+ final CharBuffer cb = dec.decode(ByteBuffer.wrap(data));
+ try {
+ s.listener.onText(cb, true);
+ } catch (final Throwable ignore) {
+ }
+ } catch (final CharacterCodingException cce) {
+ initiateCloseAndWait(s.session, 1007, "Invalid UTF-8 in text message");
+ }
+ } else if (op == FrameOpcode.BINARY) {
+ try {
+ s.listener.onBinary(ByteBuffer.wrap(data).asReadOnlyBuffer(), true);
+ } catch (final Throwable ignore) {
+ }
+ }
+ }
+
+ private void deliverSingle(final int op, final ByteBuffer payloadRO) {
+ if (op == FrameOpcode.TEXT) {
+ final CharsetDecoder dec = StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT);
+ try {
+ final CharBuffer cb = dec.decode(payloadRO);
+ try {
+ s.listener.onText(cb, true);
+ } catch (final Throwable ignore) {
+ }
+ } catch (final CharacterCodingException cce) {
+ initiateCloseAndWait(s.session, 1007, "Invalid UTF-8 in text message");
+ }
+ } else if (op == FrameOpcode.BINARY) {
+ try {
+ s.listener.onBinary(payloadRO, true);
+ } catch (final Throwable ignore) {
+ }
+ }
+ }
+
+ private static byte[] toBytes(final ByteBuffer buf) {
+ final ByteBuffer b = buf.asReadOnlyBuffer();
+ final byte[] out = new byte[b.remaining()];
+ b.get(out);
+ return out;
+ }
+
+ private void initiateCloseAndWait(final IOSession ioSession, final int code, final String reason) {
+ if (!s.closeSent.get()) {
+ try {
+ final String truncated = CloseCodec.truncateReasonUtf8(reason);
+ final byte[] payloadBytes = CloseCodec.encode(code, truncated);
+ out.enqueueCtrl(out.pooledFrame(FrameOpcode.CLOSE, ByteBuffer.wrap(payloadBytes), true));
+ } catch (final Throwable ignore) {
+ }
+ s.session.setSocketTimeout(s.cfg.getCloseWaitTimeout());
+ }
+ notifyCloseOnce(code, reason);
+ }
+
+ private void notifyCloseOnce(final int code, final String reason) {
+ if (s.open.getAndSet(false)) {
+ try {
+ s.listener.onClose(code, reason == null ? "" : reason);
+ } catch (final Throwable ignore) {
+ }
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandler.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandler.java
new file mode 100644
index 0000000000..f45720c487
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandler.java
@@ -0,0 +1,121 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.transport;
+
+import java.nio.ByteBuffer;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.hc.client5.http.websocket.api.WebSocket;
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.client5.http.websocket.core.extension.ExtensionChain;
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.http.nio.AsyncClientEndpoint;
+import org.apache.hc.core5.http.nio.command.ShutdownCommand;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.reactor.Command;
+import org.apache.hc.core5.reactor.EventMask;
+import org.apache.hc.core5.reactor.IOEventHandler;
+import org.apache.hc.core5.reactor.IOSession;
+import org.apache.hc.core5.reactor.ProtocolIOSession;
+import org.apache.hc.core5.util.Timeout;
+
+/**
+ * RFC6455/7692 WebSocket handler front-end. Delegates to WsInbound / WsOutbound.
+ */
+@Internal
+public final class WebSocketIoHandler implements IOEventHandler {
+
+ private final WebSocketSessionState state;
+ private final WebSocketInbound inbound;
+ private final WebSocketOutbound outbound;
+ private final AsyncClientEndpoint endpoint;
+ private final AtomicBoolean endpointReleased;
+
+ public WebSocketIoHandler(final ProtocolIOSession session,
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfg,
+ final ExtensionChain chain,
+ final AsyncClientEndpoint endpoint) {
+ this.state = new WebSocketSessionState(session, listener, cfg, chain);
+ this.outbound = new WebSocketOutbound(state);
+ this.inbound = new WebSocketInbound(state, outbound);
+ this.endpoint = endpoint;
+ this.endpointReleased = new AtomicBoolean(false);
+ }
+
+ /**
+ * Expose the application WebSocket facade.
+ */
+ public WebSocket exposeWebSocket() {
+ return outbound.facade();
+ }
+
+ // ---- IOEventHandler ----
+ @Override
+ public void connected(final IOSession ioSession) {
+ inbound.onConnected(ioSession);
+ }
+
+ @Override
+ public void inputReady(final IOSession ioSession, final ByteBuffer src) {
+ inbound.onInputReady(ioSession, src);
+ }
+
+ @Override
+ public void outputReady(final IOSession ioSession) {
+ outbound.onOutputReady(ioSession);
+ }
+
+ @Override
+ public void timeout(final IOSession ioSession, final Timeout timeout) {
+ inbound.onTimeout(ioSession, timeout);
+ // Best-effort graceful close on timeout
+ ioSession.close(CloseMode.GRACEFUL);
+ }
+
+ @Override
+ public void exception(final IOSession ioSession, final Exception cause) {
+ inbound.onException(ioSession, cause);
+ ioSession.close(CloseMode.GRACEFUL);
+ }
+
+ @Override
+ public void disconnected(final IOSession ioSession) {
+ inbound.onDisconnected(ioSession);
+ ioSession.clearEvent(EventMask.READ | EventMask.WRITE);
+ // Ensure the underlying protocol session does not linger
+ state.session.enqueue(new ShutdownCommand(CloseMode.GRACEFUL), Command.Priority.IMMEDIATE);
+ if (endpoint != null && endpointReleased.compareAndSet(false, true)) {
+ try {
+ endpoint.releaseAndDiscard();
+ } catch (final Throwable ignore) {
+ // best effort
+ }
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java
new file mode 100644
index 0000000000..700833d68f
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java
@@ -0,0 +1,577 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.transport;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+import org.apache.hc.client5.http.websocket.api.WebSocket;
+import org.apache.hc.client5.http.websocket.core.extension.WebSocketExtensionChain;
+import org.apache.hc.client5.http.websocket.core.frame.FrameOpcode;
+import org.apache.hc.client5.http.websocket.core.message.CloseCodec;
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.reactor.EventMask;
+import org.apache.hc.core5.reactor.IOSession;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * Outbound path: frame building, queues, writing, and the app-facing WebSocket facade.
+ */
+@Internal
+final class WebSocketOutbound {
+
+ static final class OutFrame {
+
+ final ByteBuffer buf;
+ final boolean pooled;
+
+ OutFrame(final ByteBuffer buf, final boolean pooled) {
+ this.buf = buf;
+ this.pooled = pooled;
+ }
+ }
+
+ private final WebSocketSessionState s;
+ private final WebSocket facade;
+
+ WebSocketOutbound(final WebSocketSessionState s) {
+ this.s = s;
+ this.facade = new Facade();
+ }
+
+ WebSocket facade() {
+ return facade;
+ }
+
+ // ---------------------------------------------------- IO writing ---------
+
+ void onOutputReady(final IOSession ioSession) {
+ try {
+ int framesThisTick = 0;
+
+ while (framesThisTick < s.maxFramesPerTick) {
+
+ if (s.activeWrite != null && s.activeWrite.buf.hasRemaining()) {
+ final int written = ioSession.write(s.activeWrite.buf);
+ if (written == 0) {
+ ioSession.setEvent(EventMask.WRITE);
+ return;
+ }
+ if (!s.activeWrite.buf.hasRemaining()) {
+ release(s.activeWrite);
+ s.activeWrite = null;
+ framesThisTick++;
+ } else {
+ ioSession.setEvent(EventMask.WRITE);
+ return;
+ }
+ continue;
+ }
+
+ final OutFrame ctrl = s.ctrlOutbound.poll();
+ if (ctrl != null) {
+ s.activeWrite = ctrl;
+ continue;
+ }
+
+ final OutFrame data = s.dataOutbound.poll();
+ if (data != null) {
+ s.activeWrite = data;
+ continue;
+ }
+
+ ioSession.clearEvent(EventMask.WRITE);
+ if (s.closeAfterFlush && s.activeWrite == null && s.ctrlOutbound.isEmpty() && s.dataOutbound.isEmpty()) {
+ ioSession.close(CloseMode.GRACEFUL);
+ }
+ return;
+ }
+
+ if (s.activeWrite != null && s.activeWrite.buf.hasRemaining()) {
+ ioSession.setEvent(EventMask.WRITE);
+ } else {
+ ioSession.clearEvent(EventMask.WRITE);
+ }
+
+ if (s.closeAfterFlush && s.activeWrite == null && s.ctrlOutbound.isEmpty() && s.dataOutbound.isEmpty()) {
+ ioSession.close(CloseMode.GRACEFUL);
+ }
+
+ } catch (final Exception ex) {
+ try {
+ s.listener.onError(ex);
+ } finally {
+ s.session.close(CloseMode.GRACEFUL);
+ }
+ }
+ }
+
+ private void release(final OutFrame frame) {
+ if (frame.pooled) {
+ s.bufferPool.release(frame.buf);
+ }
+ }
+
+ boolean enqueueCtrl(final OutFrame frame) {
+ final boolean closeFrame = isCloseFrame(frame.buf);
+
+ if (!closeFrame && (!s.open.get() || s.closeSent.get())) {
+ release(frame);
+ return false;
+ }
+
+ if (closeFrame) {
+ if (!s.closeSent.compareAndSet(false, true)) {
+ release(frame);
+ return false;
+ }
+ } else {
+ final int max = s.cfg.getMaxOutboundControlQueue();
+ if (max > 0 && s.ctrlOutbound.size() >= max) {
+ release(frame);
+ return false;
+ }
+ }
+ s.ctrlOutbound.offer(frame);
+ s.session.setEvent(EventMask.WRITE);
+ return true;
+ }
+
+
+ boolean enqueueData(final OutFrame frame) {
+ if (!s.open.get() || s.closeSent.get()) {
+ release(frame);
+ return false;
+ }
+ s.dataOutbound.offer(frame);
+ s.session.setEvent(EventMask.WRITE);
+ return true;
+ }
+
+ private static boolean isCloseFrame(final ByteBuffer buf) {
+ if (buf.remaining() < 2) {
+ return false;
+ }
+ final int pos = buf.position();
+ final byte b1 = buf.get(pos);
+ final int opcode = b1 & 0x0F;
+ return opcode == FrameOpcode.CLOSE;
+ }
+
+ // package-private so WebSocketInbound can use them
+ OutFrame pooledFrame(final int opcode, final ByteBuffer payload, final boolean fin) {
+ final ByteBuffer ro = payload == null ? ByteBuffer.allocate(0) : payload.asReadOnlyBuffer();
+ final int len = ro.remaining();
+
+ final int headerEstimate;
+ if (len <= 125) {
+ headerEstimate = 2 + 4; // 2-byte header + 4-byte mask
+ } else if (len <= 0xFFFF) {
+ headerEstimate = 4 + 4; // 4-byte header + 4-byte mask
+ } else {
+ headerEstimate = 10 + 4; // 10-byte header + 4-byte mask
+ }
+
+ final int totalSize = headerEstimate + len;
+
+ final ByteBuffer buf;
+ final boolean pooled;
+ if (totalSize <= s.bufferPool.getBufferSize()) {
+ buf = s.bufferPool.acquire();
+ pooled = true;
+ } else {
+ buf = ByteBuffer.allocate(totalSize);
+ pooled = false;
+ }
+
+ buf.clear();
+ // opcode (int), payload (ByteBuffer), fin (boolean), mask (boolean), out (ByteBuffer)
+ s.writer.frameInto(opcode, ro, fin, true, buf);
+ buf.flip();
+
+ return new OutFrame(buf, pooled);
+ }
+
+ // package-private for outbound compression (RSV1 when negotiated)
+ OutFrame pooledFrameWithRsv(final int opcode, final ByteBuffer payload, final boolean fin, final int rsvBits) {
+ final ByteBuffer ro = payload == null ? ByteBuffer.allocate(0) : payload.asReadOnlyBuffer();
+ final int len = ro.remaining();
+
+ final int headerEstimate;
+ if (len <= 125) {
+ headerEstimate = 2 + 4;
+ } else if (len <= 0xFFFF) {
+ headerEstimate = 4 + 4;
+ } else {
+ headerEstimate = 10 + 4;
+ }
+
+ final int totalSize = headerEstimate + len;
+
+ final ByteBuffer buf;
+ final boolean pooled;
+ if (totalSize <= s.bufferPool.getBufferSize()) {
+ buf = s.bufferPool.acquire();
+ pooled = true;
+ } else {
+ buf = ByteBuffer.allocate(totalSize);
+ pooled = false;
+ }
+
+ buf.clear();
+ s.writer.frameIntoWithRSV(opcode, ro, fin, true, rsvBits, buf);
+ buf.flip();
+
+ return new OutFrame(buf, pooled);
+ }
+
+ // package-private so WebSocketInbound can use it for close echo
+ OutFrame pooledCloseEcho(final ByteBuffer payload) {
+ final ByteBuffer ro = payload == null ? ByteBuffer.allocate(0) : payload.asReadOnlyBuffer();
+ final int len = ro.remaining();
+
+ final int headerEstimate;
+ if (len <= 125) {
+ headerEstimate = 2 + 4;
+ } else if (len <= 0xFFFF) {
+ headerEstimate = 4 + 4;
+ } else {
+ headerEstimate = 10 + 4;
+ }
+
+ final int totalSize = headerEstimate + len;
+
+ final ByteBuffer buf;
+ final boolean pooled;
+ if (totalSize <= s.bufferPool.getBufferSize()) {
+ buf = s.bufferPool.acquire();
+ pooled = true;
+ } else {
+ buf = ByteBuffer.allocate(totalSize);
+ pooled = false;
+ }
+
+ buf.clear();
+ s.writer.frameInto(FrameOpcode.CLOSE, ro, true, true, buf);
+ buf.flip();
+
+ return new OutFrame(buf, pooled);
+ }
+
+ // package-private: used by WebSocketInbound.onDisconnected()
+ void drainAndRelease() {
+ if (s.activeWrite != null) {
+ release(s.activeWrite);
+ s.activeWrite = null;
+ }
+ OutFrame f;
+ while ((f = s.ctrlOutbound.poll()) != null) {
+ release(f);
+ }
+ while ((f = s.dataOutbound.poll()) != null) {
+ release(f);
+ }
+ }
+
+ // --------------------------------------------------------- Facade --------
+
+ private final class Facade implements WebSocket {
+
+ @Override
+ public boolean isOpen() {
+ return s.open.get() && !s.closeSent.get();
+ }
+
+ @Override
+ public boolean ping(final ByteBuffer data) {
+ if (!s.open.get() || s.closeSent.get()) {
+ return false;
+ }
+ final ByteBuffer ro = data == null ? ByteBuffer.allocate(0) : data.asReadOnlyBuffer();
+ if (ro.remaining() > 125) {
+ return false;
+ }
+ return enqueueCtrl(pooledFrame(FrameOpcode.PING, ro, true));
+ }
+
+ @Override
+ public boolean pong(final ByteBuffer data) {
+ if (!s.open.get() || s.closeSent.get()) {
+ return false;
+ }
+ final ByteBuffer ro = data == null ? ByteBuffer.allocate(0) : data.asReadOnlyBuffer();
+ if (ro.remaining() > 125) {
+ return false;
+ }
+ return enqueueCtrl(pooledFrame(FrameOpcode.PONG, ro, true));
+ }
+
+ @Override
+ public boolean sendText(final CharSequence data, final boolean finalFragment) {
+ if (!s.open.get() || s.closeSent.get() || data == null) {
+ return false;
+ }
+ final ByteBuffer utf8 = StandardCharsets.UTF_8.encode(data.toString());
+ return sendData(FrameOpcode.TEXT, utf8, finalFragment);
+ }
+
+ @Override
+ public boolean sendBinary(final ByteBuffer data, final boolean finalFragment) {
+ if (!s.open.get() || s.closeSent.get() || data == null) {
+ return false;
+ }
+ return sendData(FrameOpcode.BINARY, data, finalFragment);
+ }
+
+ private boolean sendData(final int opcode, final ByteBuffer data, final boolean fin) {
+ synchronized (s.writeLock) {
+ if (s.encChain != null && s.outOpcode == -1 && fin) {
+ // Compress the whole message, then fragment the compressed payload.
+ final byte[] plain = toBytes(data);
+ final WebSocketExtensionChain.Encoded enc =
+ s.encChain.encode(plain, true, true);
+ ByteBuffer ro = ByteBuffer.wrap(enc.payload);
+ int currentOpcode = opcode;
+ boolean firstFragment = true;
+ if (!ro.hasRemaining()) {
+ ro = ByteBuffer.allocate(0);
+ }
+ do {
+ if (!s.open.get() || s.closeSent.get()) {
+ return false;
+ }
+ final int n = Math.min(ro.remaining(), s.outChunk);
+ final int oldLimit = ro.limit();
+ final int newLimit = ro.position() + n;
+ ro.limit(newLimit);
+ final ByteBuffer slice = ro.slice();
+ ro.limit(oldLimit);
+ ro.position(newLimit);
+ final boolean lastSlice = !ro.hasRemaining();
+ final int rsv = enc.setRsvOnFirst && firstFragment ? s.rsvMask : 0;
+ if (!enqueueData(pooledFrameWithRsv(currentOpcode, slice, lastSlice, rsv))) {
+ return false;
+ }
+ currentOpcode = FrameOpcode.CONT;
+ firstFragment = false;
+ } while (ro.hasRemaining());
+ return true;
+ }
+
+ int currentOpcode = s.outOpcode == -1 ? opcode : FrameOpcode.CONT;
+ if (s.outOpcode == -1) {
+ s.outOpcode = opcode;
+ }
+
+ final ByteBuffer ro = data.asReadOnlyBuffer();
+ boolean ok = true;
+ boolean firstFragment = currentOpcode != FrameOpcode.CONT;
+
+ while (ro.hasRemaining()) {
+ if (!s.open.get() || s.closeSent.get()) {
+ ok = false;
+ break;
+ }
+
+ final int n = Math.min(ro.remaining(), s.outChunk);
+
+ final int oldLimit = ro.limit();
+ final int newLimit = ro.position() + n;
+ ro.limit(newLimit);
+ final ByteBuffer slice = ro.slice();
+ ro.limit(oldLimit);
+ ro.position(newLimit);
+
+ final boolean lastSlice = !ro.hasRemaining() && fin;
+ if (!enqueueData(buildDataFrame(currentOpcode, slice, lastSlice, firstFragment))) {
+ ok = false;
+ break;
+ }
+ currentOpcode = FrameOpcode.CONT;
+ firstFragment = false;
+ }
+
+ if (fin || !ok) {
+ s.outOpcode = -1;
+ }
+ return ok;
+ }
+ }
+
+
+ @Override
+ public boolean sendTextBatch(final List fragments, final boolean finalFragment) {
+ if (!s.open.get() || s.closeSent.get() || fragments == null || fragments.isEmpty()) {
+ return false;
+ }
+ synchronized (s.writeLock) {
+ int currentOpcode = s.outOpcode == -1 ? FrameOpcode.TEXT : FrameOpcode.CONT;
+ if (s.outOpcode == -1) {
+ s.outOpcode = FrameOpcode.TEXT;
+ }
+ boolean firstFragment = currentOpcode != FrameOpcode.CONT;
+
+ for (int i = 0; i < fragments.size(); i++) {
+ final CharSequence part = Args.notNull(fragments.get(i), "fragment");
+ final ByteBuffer utf8 = StandardCharsets.UTF_8.encode(part.toString());
+ final ByteBuffer ro = utf8.asReadOnlyBuffer();
+
+ while (ro.hasRemaining()) {
+ if (!s.open.get() || s.closeSent.get()) {
+ s.outOpcode = -1;
+ return false;
+ }
+ final int n = Math.min(ro.remaining(), s.outChunk);
+
+ final int oldLimit = ro.limit();
+ final int newLimit = ro.position() + n;
+ ro.limit(newLimit);
+ final ByteBuffer slice = ro.slice();
+ ro.limit(oldLimit);
+ ro.position(newLimit);
+
+ final boolean isLastFragment = i == fragments.size() - 1;
+ final boolean lastSlice = !ro.hasRemaining() && isLastFragment && finalFragment;
+
+ if (!enqueueData(buildDataFrame(currentOpcode, slice, lastSlice, firstFragment))) {
+ s.outOpcode = -1;
+ return false;
+ }
+ currentOpcode = FrameOpcode.CONT;
+ firstFragment = false;
+ }
+ }
+
+ if (finalFragment) {
+ s.outOpcode = -1;
+ }
+ return true;
+ }
+ }
+
+ @Override
+ public boolean sendBinaryBatch(final List fragments, final boolean finalFragment) {
+ if (!s.open.get() || s.closeSent.get() || fragments == null || fragments.isEmpty()) {
+ return false;
+ }
+ synchronized (s.writeLock) {
+ int currentOpcode = s.outOpcode == -1 ? FrameOpcode.BINARY : FrameOpcode.CONT;
+ if (s.outOpcode == -1) {
+ s.outOpcode = FrameOpcode.BINARY;
+ }
+ boolean firstFragment = currentOpcode != FrameOpcode.CONT;
+
+ for (int i = 0; i < fragments.size(); i++) {
+ final ByteBuffer src = Args.notNull(fragments.get(i), "fragment").asReadOnlyBuffer();
+
+ while (src.hasRemaining()) {
+ if (!s.open.get() || s.closeSent.get()) {
+ s.outOpcode = -1;
+ return false;
+ }
+ final int n = Math.min(src.remaining(), s.outChunk);
+
+ final int oldLimit = src.limit();
+ final int newLimit = src.position() + n;
+ src.limit(newLimit);
+ final ByteBuffer slice = src.slice();
+ src.limit(oldLimit);
+ src.position(newLimit);
+
+ final boolean isLastFragment = i == fragments.size() - 1;
+ final boolean lastSlice = !src.hasRemaining() && isLastFragment && finalFragment;
+
+ if (!enqueueData(buildDataFrame(currentOpcode, slice, lastSlice, firstFragment))) {
+ s.outOpcode = -1;
+ return false;
+ }
+ currentOpcode = FrameOpcode.CONT;
+ firstFragment = false;
+ }
+ }
+
+ if (finalFragment) {
+ s.outOpcode = -1;
+ }
+ return true;
+ }
+ }
+
+ @Override
+ public CompletableFuture close(final int statusCode, final String reason) {
+ final CompletableFuture future = new CompletableFuture<>();
+
+ if (!s.open.get()) {
+ future.completeExceptionally(
+ new IllegalStateException("WebSocket is already closed"));
+ return future;
+ }
+
+ if (!CloseCodec.isValidToSend(statusCode)) {
+ future.completeExceptionally(
+ new IllegalArgumentException("Invalid close status code: " + statusCode));
+ return future;
+ }
+
+ final String truncated = CloseCodec.truncateReasonUtf8(reason);
+ final byte[] payloadBytes = CloseCodec.encode(statusCode, truncated);
+ final ByteBuffer payload = ByteBuffer.wrap(payloadBytes);
+
+ if (!enqueueCtrl(pooledFrame(FrameOpcode.CLOSE, payload, true))) {
+ future.completeExceptionally(
+ new IllegalStateException("WebSocket is closing or already closed"));
+ return future;
+ }
+
+ // cfg.getCloseWaitTimeout() is a Timeout, IOSession.setSocketTimeout(Timeout)
+ s.session.setSocketTimeout(s.cfg.getCloseWaitTimeout());
+ future.complete(null);
+ return future;
+ }
+ }
+
+ private OutFrame buildDataFrame(final int opcode, final ByteBuffer payload, final boolean fin, final boolean firstFragment) {
+ if (s.encChain == null) {
+ return pooledFrame(opcode, payload, fin);
+ }
+ final byte[] plain = toBytes(payload);
+ final WebSocketExtensionChain.Encoded enc =
+ s.encChain.encode(plain, firstFragment, fin);
+ final int rsv = enc.setRsvOnFirst && firstFragment ? s.rsvMask : 0;
+ return pooledFrameWithRsv(opcode, ByteBuffer.wrap(enc.payload), fin, rsv);
+ }
+
+ private static byte[] toBytes(final ByteBuffer buf) {
+ final ByteBuffer b = buf.asReadOnlyBuffer();
+ final byte[] out = new byte[b.remaining()];
+ b.get(out);
+ return out;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.java
new file mode 100644
index 0000000000..0074825329
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.java
@@ -0,0 +1,120 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.transport;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.client5.http.websocket.core.extension.ExtensionChain;
+import org.apache.hc.client5.http.websocket.core.frame.WebSocketFrameWriter;
+import org.apache.hc.client5.http.websocket.core.util.ByteBufferPool;
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.reactor.ProtocolIOSession;
+
+/**
+ * Shared state & resources.
+ */
+@Internal
+final class WebSocketSessionState {
+
+ // External
+ final ProtocolIOSession session;
+ final WebSocketListener listener;
+ final WebSocketClientConfig cfg;
+
+ // Extensions
+ final ExtensionChain.EncodeChain encChain;
+ final ExtensionChain.DecodeChain decChain;
+ final int rsvMask;
+
+ // Buffers & codec
+ final ByteBufferPool bufferPool;
+ final WebSocketFrameWriter writer = new WebSocketFrameWriter();
+ final WebSocketFrameDecoder decoder;
+
+ // Read side
+ ByteBuffer readBuf;
+ ByteBuffer inbuf = ByteBuffer.allocate(4096);
+
+ // Outbound queues
+ final ConcurrentLinkedQueue ctrlOutbound = new ConcurrentLinkedQueue<>();
+ final ConcurrentLinkedQueue dataOutbound = new ConcurrentLinkedQueue<>();
+ WebSocketOutbound.OutFrame activeWrite = null;
+
+ // Flags / locks
+ final AtomicBoolean open = new AtomicBoolean(true);
+ final AtomicBoolean closeSent = new AtomicBoolean(false);
+ final AtomicBoolean closeReceived = new AtomicBoolean(false);
+ volatile boolean closeAfterFlush = false;
+ final Object writeLock = new Object();
+
+ // Message assembly
+ int assemblingOpcode = -1;
+ boolean assemblingCompressed = false;
+ ByteArrayOutputStream assemblingBytes = null;
+ long assemblingSize = 0L;
+
+ // Outbound fragmentation
+ int outOpcode = -1;
+ final int outChunk;
+ final int maxFramesPerTick;
+
+ WebSocketSessionState(final ProtocolIOSession session,
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfg,
+ final ExtensionChain chain) {
+ this.session = session;
+ this.listener = listener;
+ this.cfg = cfg;
+
+ this.decoder = new WebSocketFrameDecoder(cfg.getMaxFrameSize(), false);
+
+ this.outChunk = Math.max(256, cfg.getOutgoingChunkSize());
+ this.maxFramesPerTick = Math.max(1, cfg.getMaxFramesPerTick());
+
+ if (chain != null && !chain.isEmpty()) {
+ this.encChain = chain.newEncodeChain();
+ this.decChain = chain.newDecodeChain();
+ this.rsvMask = chain.rsvMask();
+ } else {
+ this.encChain = null;
+ this.decChain = null;
+ this.rsvMask = 0;
+ }
+
+ final int poolBufSize = Math.max(8192, this.outChunk);
+ final int poolCapacity = Math.max(16, cfg.getIoPoolCapacity());
+ this.bufferPool = new ByteBufferPool(poolBufSize, poolCapacity, cfg.isDirectBuffers());
+
+ // Borrow one read buffer upfront
+ this.readBuf = bufferPool.acquire();
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgrader.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgrader.java
new file mode 100644
index 0000000000..c94f8473d7
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgrader.java
@@ -0,0 +1,114 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.transport;
+
+import org.apache.hc.client5.http.websocket.api.WebSocket;
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.client5.http.websocket.core.extension.ExtensionChain;
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.nio.AsyncClientEndpoint;
+import org.apache.hc.core5.reactor.ProtocolIOSession;
+import org.apache.hc.core5.reactor.ProtocolUpgradeHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Bridges HttpCore protocol upgrade to a WebSocket {@link WebSocketIoHandler}.
+ *
+ * IMPORTANT: This class does NOT call {@link WebSocketListener#onOpen(WebSocket)}.
+ * The caller performs notification after {@code switchProtocol(...)} completes.
+ */
+@Internal
+public final class WebSocketUpgrader implements ProtocolUpgradeHandler {
+
+ private static final Logger LOG = LoggerFactory.getLogger(WebSocketUpgrader.class);
+
+ private final WebSocketListener listener;
+ private final WebSocketClientConfig cfg;
+ private final ExtensionChain chain;
+ private final AsyncClientEndpoint endpoint;
+
+ /**
+ * The WebSocket facade created during {@link #upgrade}.
+ */
+ private volatile WebSocket webSocket;
+
+ public WebSocketUpgrader(
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfg,
+ final ExtensionChain chain) {
+ this(listener, cfg, chain, null);
+ }
+
+ public WebSocketUpgrader(
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfg,
+ final ExtensionChain chain,
+ final AsyncClientEndpoint endpoint) {
+ this.listener = listener;
+ this.cfg = cfg;
+ this.chain = chain;
+ this.endpoint = endpoint;
+ }
+
+ /**
+ * Returns the {@link WebSocket} created during {@link #upgrade}.
+ */
+ public WebSocket getWebSocket() {
+ return webSocket;
+ }
+
+ @Override
+ public void upgrade(final ProtocolIOSession ioSession,
+ final FutureCallback callback) {
+ try {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Installing WsHandler on {}", ioSession);
+ }
+
+ final WebSocketIoHandler handler = new WebSocketIoHandler(ioSession, listener, cfg, chain, endpoint);
+ ioSession.upgrade(handler);
+
+ this.webSocket = handler.exposeWebSocket();
+
+ if (callback != null) {
+ callback.completed(ioSession);
+ }
+ } catch (final Exception ex) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("WebSocket upgrade failed", ex);
+ }
+ if (callback != null) {
+ callback.failed(ex);
+ } else {
+ throw ex;
+ }
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/package-info.java
new file mode 100644
index 0000000000..67cff0dbea
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/package-info.java
@@ -0,0 +1,37 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * Integration with Apache HttpCore I/O reactor.
+ *
+ * Protocol upgrade hooks and the reactor {@code IOEventHandler} that
+ * implements RFC 6455/7692 on top of HttpCore. Internal API — subject
+ * to change without notice.
+ *
+ * @since 5.7
+ */
+package org.apache.hc.client5.http.websocket.transport;
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfigTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfigTest.java
new file mode 100644
index 0000000000..d4d94b2ff0
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfigTest.java
@@ -0,0 +1,57 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.api;
+
+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 org.apache.hc.core5.util.Timeout;
+import org.junit.jupiter.api.Test;
+
+final class WebSocketClientConfigTest {
+
+ @Test
+ void builderDefaultsAndCustom() {
+ final WebSocketClientConfig def = WebSocketClientConfig.custom().build();
+ assertTrue(def.isAutoPong());
+ assertTrue(def.getMaxFrameSize() > 0);
+ assertTrue(def.getMaxMessageSize() > 0);
+
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .setAutoPong(false)
+ .setMaxFrameSize(1024)
+ .setMaxMessageSize(2048)
+ .setConnectTimeout(Timeout.ofSeconds(3))
+ .build();
+
+ assertFalse(cfg.isAutoPong());
+ assertEquals(1024, cfg.getMaxFrameSize());
+ assertEquals(2048, cfg.getMaxMessageSize());
+ assertEquals(Timeout.ofSeconds(3), cfg.getConnectTimeout());
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/WebSocketClientTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/WebSocketClientTest.java
new file mode 100644
index 0000000000..5e233d0ef9
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/WebSocketClientTest.java
@@ -0,0 +1,344 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hc.client5.http.websocket.api.WebSocket;
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.reactor.IOReactorStatus;
+import org.apache.hc.core5.util.TimeValue;
+import org.junit.jupiter.api.Test;
+
+final class WebSocketClientTest {
+
+ private static final class NoNetworkClient extends CloseableWebSocketClient {
+
+ @Override
+ public void start() {
+ // no-op
+ }
+
+ @Override
+ public IOReactorStatus getStatus() {
+ return IOReactorStatus.ACTIVE;
+ }
+
+ @Override
+ public void awaitShutdown(final TimeValue waitTime) {
+ // no-op
+ }
+
+ @Override
+ public void initiateShutdown() {
+ // no-op
+ }
+
+ // ModalCloseable (if your ModalCloseable declares this)
+ public void close(final CloseMode closeMode) {
+ // no-op
+ }
+
+ // Closeable
+ @Override
+ public void close() {
+ // no-op – needed for try-with-resources
+ }
+
+ @Override
+ protected CompletableFuture doConnect(
+ final URI uri,
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfg,
+ final HttpContext context) {
+
+ final CompletableFuture f = new CompletableFuture<>();
+ final LocalLoopWebSocket ws = new LocalLoopWebSocket(listener, cfg);
+ try {
+ listener.onOpen(ws);
+ } catch (final Throwable ignore) {
+ }
+ f.complete(ws);
+ return f;
+ }
+ }
+
+ private static final class LocalLoopWebSocket implements WebSocket {
+ private final WebSocketListener listener;
+ private final WebSocketClientConfig cfg;
+ private volatile boolean open = true;
+
+ LocalLoopWebSocket(final WebSocketListener listener, final WebSocketClientConfig cfg) {
+ this.listener = listener;
+ this.cfg = cfg != null ? cfg : WebSocketClientConfig.custom().build();
+ }
+
+ @Override
+ public boolean sendText(final CharSequence data, final boolean finalFragment) {
+ if (!open) {
+ return false;
+ }
+ if (cfg.getMaxMessageSize() > 0 && data != null && data.length() > cfg.getMaxMessageSize()) {
+ // Simulate client closing due to oversized message
+ try {
+ listener.onClose(1009, "Message too big");
+ } catch (final Throwable ignore) {
+ }
+ open = false;
+ return false;
+ }
+ try {
+ final CharBuffer cb = data != null ? CharBuffer.wrap(data) : CharBuffer.allocate(0);
+ listener.onText(cb, finalFragment);
+ } catch (final Throwable ignore) {
+ }
+ return true;
+ }
+
+ @Override
+ public boolean sendBinary(final ByteBuffer data, final boolean finalFragment) {
+ if (!open) {
+ return false;
+ }
+ try {
+ listener.onBinary(data != null ? data.asReadOnlyBuffer() : ByteBuffer.allocate(0), finalFragment);
+ } catch (final Throwable ignore) {
+ }
+ return true;
+ }
+
+ @Override
+ public boolean ping(final ByteBuffer data) {
+ if (!open) {
+ return false;
+ }
+ try {
+ listener.onPong(data != null ? data.asReadOnlyBuffer() : ByteBuffer.allocate(0));
+ } catch (final Throwable ignore) {
+ }
+ return true;
+ }
+
+ @Override
+ public boolean pong(final ByteBuffer data) {
+ // In a real client this would send a PONG; here it's a no-op.
+ return open;
+ }
+
+ @Override
+ public CompletableFuture close(final int statusCode, final String reason) {
+ final CompletableFuture f = new CompletableFuture<>();
+ if (!open) {
+ f.complete(null);
+ return f;
+ }
+ open = false;
+ try {
+ listener.onClose(statusCode, reason != null ? reason : "");
+ } catch (final Throwable ignore) {
+ }
+ f.complete(null);
+ return f;
+ }
+
+ @Override
+ public boolean isOpen() {
+ return open;
+ }
+
+ @Override
+ public boolean sendTextBatch(final List fragments, final boolean finalFragment) {
+ if (!open) {
+ return false;
+ }
+ if (fragments == null || fragments.isEmpty()) {
+ return true;
+ }
+ for (int i = 0; i < fragments.size(); i++) {
+ final boolean last = i == fragments.size() - 1 && finalFragment;
+ if (!sendText(fragments.get(i), last)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public boolean sendBinaryBatch(final List fragments, final boolean finalFragment) {
+ if (!open) {
+ return false;
+ }
+ if (fragments == null || fragments.isEmpty()) {
+ return true;
+ }
+ for (int i = 0; i < fragments.size(); i++) {
+ final boolean last = i == fragments.size() - 1 && finalFragment;
+ if (!sendBinary(fragments.get(i), last)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+
+ private static CloseableWebSocketClient newClient() {
+ final CloseableWebSocketClient c = new NoNetworkClient();
+ c.start();
+ return c;
+ }
+
+ // ------------------------------- Tests -----------------------------------
+
+ @Test
+ void echo_uncompressed_no_network() throws Exception {
+ final CountDownLatch done = new CountDownLatch(1);
+ final StringBuilder echoed = new StringBuilder();
+
+ try (final CloseableWebSocketClient client = newClient()) {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .enablePerMessageDeflate(false)
+ .build();
+
+ client.connect(URI.create("ws://example/echo"), new WebSocketListener() {
+ private WebSocket ws;
+
+ @Override
+ public void onOpen(final WebSocket ws) {
+ this.ws = ws;
+ final String prefix = "hello @ " + Instant.now() + " — ";
+ final StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < 16; i++) {
+ sb.append(prefix);
+ }
+ ws.sendText(sb, true);
+ }
+
+ @Override
+ public void onText(final CharBuffer text, final boolean last) {
+ echoed.append(text);
+ ws.close(1000, "done");
+ }
+
+ @Override
+ public void onClose(final int code, final String reason) {
+ assertEquals(1000, code);
+ assertEquals("done", reason);
+ assertTrue(echoed.length() > 0);
+ done.countDown();
+ }
+
+ @Override
+ public void onError(final Throwable ex) {
+ done.countDown();
+ }
+ }, cfg, null);
+
+ assertTrue(done.await(3, TimeUnit.SECONDS));
+ }
+ }
+
+ @Test
+ void ping_interleaved_fragmentation_no_network() throws Exception {
+ final CountDownLatch gotText = new CountDownLatch(1);
+ final CountDownLatch gotPong = new CountDownLatch(1);
+
+ try (final CloseableWebSocketClient client = newClient()) {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .enablePerMessageDeflate(false)
+ .build();
+
+ client.connect(URI.create("ws://example/interleave"), new WebSocketListener() {
+
+ @Override
+ public void onOpen(final WebSocket ws) {
+ ws.ping(StandardCharsets.UTF_8.encode("ping"));
+ ws.sendText("hello", true);
+ }
+
+ @Override
+ public void onText(final CharBuffer text, final boolean last) {
+ gotText.countDown();
+ }
+
+ @Override
+ public void onPong(final ByteBuffer payload) {
+ gotPong.countDown();
+ }
+ }, cfg, null);
+
+ assertTrue(gotPong.await(2, TimeUnit.SECONDS));
+ assertTrue(gotText.await(2, TimeUnit.SECONDS));
+ }
+ }
+
+ @Test
+ void max_message_1009_no_network() throws Exception {
+ final CountDownLatch done = new CountDownLatch(1);
+ final int maxMessage = 2048;
+
+ try (final CloseableWebSocketClient client = newClient()) {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .setMaxMessageSize(maxMessage)
+ .enablePerMessageDeflate(false)
+ .build();
+
+ client.connect(URI.create("ws://example/echo"), new WebSocketListener() {
+ @Override
+ public void onOpen(final WebSocket ws) {
+ final StringBuilder sb = new StringBuilder();
+ final String chunk = "1234567890abcdef-";
+ while (sb.length() <= maxMessage * 2) {
+ sb.append(chunk);
+ }
+ ws.sendText(sb, true);
+ }
+
+ @Override
+ public void onClose(final int code, final String reason) {
+ assertEquals(1009, code);
+ done.countDown();
+ }
+ }, cfg, null);
+
+ assertTrue(done.await(2, TimeUnit.SECONDS));
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocolExtensionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocolExtensionTest.java
new file mode 100644
index 0000000000..cf5d7fa7da
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocolExtensionTest.java
@@ -0,0 +1,106 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.client.impl.protocol;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.core.extension.ExtensionChain;
+import org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits;
+import org.junit.jupiter.api.Test;
+
+final class Http1UpgradeProtocolExtensionTest {
+
+ @Test
+ void pmce_rejectedWhenDisabled() {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .enablePerMessageDeflate(false)
+ .build();
+ assertThrows(IllegalStateException.class, () ->
+ Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate"));
+ }
+
+ @Test
+ void pmce_rejectedWhenParametersNotOffered() {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .offerServerNoContextTakeover(false)
+ .offerClientNoContextTakeover(false)
+ .offerClientMaxWindowBits(null)
+ .offerServerMaxWindowBits(null)
+ .build();
+ assertThrows(IllegalStateException.class, () ->
+ Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate; server_no_context_takeover"));
+ assertThrows(IllegalStateException.class, () ->
+ Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate; client_max_window_bits=15"));
+ }
+
+ @Test
+ void pmce_rejectedOnUnknownOrDuplicate() {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build();
+ assertThrows(IllegalStateException.class, () ->
+ Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate; unknown=1"));
+ assertThrows(IllegalStateException.class, () ->
+ Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate, permessage-deflate"));
+ }
+
+ @Test
+ void pmce_rejectedOnUnsupportedClientWindowBits() {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .offerClientMaxWindowBits(15)
+ .offerServerMaxWindowBits(15)
+ .build();
+ assertThrows(IllegalStateException.class, () ->
+ Http1UpgradeProtocol.buildExtensionChain(cfg, "permessage-deflate; client_max_window_bits=12"));
+ }
+
+ @Test
+ void pmce_acceptsServerWindowBitsBelow15() {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .offerServerMaxWindowBits(15)
+ .build();
+ final ExtensionChain chain = Http1UpgradeProtocol.buildExtensionChain(cfg,
+ "permessage-deflate; server_max_window_bits=12");
+ assertFalse(chain.isEmpty());
+ assertEquals(FrameHeaderBits.RSV1, chain.rsvMask());
+ }
+
+ @Test
+ void pmce_validNegotiation_buildsChain() {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .offerClientMaxWindowBits(15)
+ .offerServerMaxWindowBits(15)
+ .offerClientNoContextTakeover(true)
+ .offerServerNoContextTakeover(true)
+ .build();
+ final ExtensionChain chain = Http1UpgradeProtocol.buildExtensionChain(cfg,
+ "permessage-deflate; client_no_context_takeover; server_no_context_takeover; client_max_window_bits=15");
+ assertFalse(chain.isEmpty());
+ assertEquals(FrameHeaderBits.RSV1, chain.rsvMask());
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChainTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChainTest.java
new file mode 100644
index 0000000000..39408db577
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChainTest.java
@@ -0,0 +1,50 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.core.extension;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+final class ExtensionChainTest {
+
+ @Test
+ void addAndUsePmce_decodeRoundTrip() throws Exception {
+ final ExtensionChain chain = new ExtensionChain();
+ final PerMessageDeflate pmce = new PerMessageDeflate(true, true, true, null, null);
+ chain.add(pmce);
+
+ final byte[] data = "compress me please".getBytes(StandardCharsets.UTF_8);
+
+ final WebSocketExtensionChain.Encoded enc = pmce.newEncoder().encode(data, true, true);
+ final byte[] back = chain.newDecodeChain().decode(enc.payload);
+
+ assertArrayEquals(data, back);
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/MessageDeflateTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/MessageDeflateTest.java
new file mode 100644
index 0000000000..1436c98353
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/MessageDeflateTest.java
@@ -0,0 +1,90 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.core.extension;
+
+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.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+
+import org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits;
+import org.junit.jupiter.api.Test;
+
+final class MessageDeflateTest {
+
+ @Test
+ void rsvMask_isRSV1() {
+ final PerMessageDeflate pmce = new PerMessageDeflate(true, false, false, null, null);
+ assertEquals(FrameHeaderBits.RSV1, pmce.rsvMask());
+ }
+
+ @Test
+ void encode_setsRSVOnlyOnFirst() {
+ final PerMessageDeflate pmce = new PerMessageDeflate(true, false, false, null, null);
+ final WebSocketExtensionChain.Encoder enc = pmce.newEncoder();
+
+ final byte[] data = "hello".getBytes(StandardCharsets.UTF_8);
+
+ final WebSocketExtensionChain.Encoded first = enc.encode(data, true, false);
+ final WebSocketExtensionChain.Encoded cont = enc.encode(data, false, true);
+
+ assertTrue(first.setRsvOnFirst, "RSV on first fragment");
+ assertFalse(cont.setRsvOnFirst, "no RSV on continuation");
+ assertNotEquals(0, first.payload.length);
+ assertNotEquals(0, cont.payload.length);
+ }
+
+ @Test
+ void roundTrip_message() throws Exception {
+ final PerMessageDeflate pmce = new PerMessageDeflate(true, true, true, null, null);
+ final WebSocketExtensionChain.Encoder enc = pmce.newEncoder();
+ final WebSocketExtensionChain.Decoder dec = pmce.newDecoder();
+
+ final String s = "The quick brown fox jumps over the lazy dog. "
+ + "The quick brown fox jumps over the lazy dog.";
+ final byte[] plain = s.getBytes(StandardCharsets.UTF_8);
+
+ // Single-frame message: first=true, fin=true
+ final byte[] wire = enc.encode(plain, true, true).payload;
+
+ assertTrue(wire.length > 0);
+ assertFalse(endsWithTail(wire), "tail must be stripped on wire");
+
+ final byte[] roundTrip = dec.decode(wire);
+ assertArrayEquals(plain, roundTrip);
+ }
+
+ private static boolean endsWithTail(final byte[] b) {
+ if (b.length < 4) {
+ return false;
+ }
+ return b[b.length - 4] == 0x00 && b[b.length - 3] == 0x00 && (b[b.length - 2] & 0xFF) == 0xFF && (b[b.length - 1] & 0xFF) == 0xFF;
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameReaderTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameReaderTest.java
new file mode 100644
index 0000000000..cadb431eb1
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameReaderTest.java
@@ -0,0 +1,184 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.core.frame;
+
+
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
+import org.apache.hc.client5.http.websocket.core.exceptions.WebSocketProtocolException;
+import org.apache.hc.client5.http.websocket.transport.WebSocketFrameDecoder;
+import org.junit.jupiter.api.Test;
+
+class FrameReaderTest {
+
+ private static ByteBuffer serverTextFrame(final String s) {
+ final byte[] p = s.getBytes(StandardCharsets.UTF_8);
+ final int len = p.length;
+ final ByteBuffer buf;
+ if (len <= 125) {
+ buf = ByteBuffer.allocate(2 + len);
+ buf.put((byte) 0x81); // FIN|TEXT
+ buf.put((byte) len); // no MASK
+ } else if (len <= 0xFFFF) {
+ buf = ByteBuffer.allocate(2 + 2 + len);
+ buf.put((byte) 0x81);
+ buf.put((byte) 126);
+ buf.putShort((short) len);
+ } else {
+ buf = ByteBuffer.allocate(2 + 8 + len);
+ buf.put((byte) 0x81);
+ buf.put((byte) 127);
+ buf.putLong(len);
+ }
+ buf.put(p);
+ buf.flip();
+ return buf;
+ }
+
+ @Test
+ void decode_small_text_unmasked() {
+ final ByteBuffer f = serverTextFrame("hello");
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192);
+ assertTrue(d.decode(f));
+ assertEquals(FrameOpcode.TEXT, d.opcode());
+ assertTrue(d.fin());
+ assertFalse(d.rsv1());
+ assertEquals("hello", StandardCharsets.UTF_8.decode(d.payload()).toString());
+ }
+
+ @Test
+ void decode_extended_126_length() {
+ final byte[] p = new byte[300];
+ for (int i = 0; i < p.length; i++) {
+ p[i] = (byte) (i & 0xFF);
+ }
+ final ByteBuffer f = ByteBuffer.allocate(2 + 2 + p.length);
+ f.put((byte) 0x82); // FIN|BINARY
+ f.put((byte) 126);
+ f.putShort((short) p.length);
+ f.put(p);
+ f.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(4096);
+ assertTrue(d.decode(f));
+ assertEquals(FrameOpcode.BINARY, d.opcode());
+ final ByteBuffer payload = d.payload();
+ final byte[] got = new byte[p.length];
+ payload.get(got);
+ assertArrayEquals(p, got);
+ }
+
+ @Test
+ void decode_extended_127_length() {
+ final int len = 66000;
+ final byte[] p = new byte[len];
+ Arrays.fill(p, (byte) 0xAB);
+ final ByteBuffer f = ByteBuffer.allocate(2 + 8 + len);
+ f.put((byte) 0x82); // FIN|BINARY
+ f.put((byte) 127);
+ f.putLong(len);
+ f.put(p);
+ f.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(len + 64);
+ assertTrue(d.decode(f));
+ assertEquals(len, d.payload().remaining());
+ }
+
+ @Test
+ void masked_server_frame_is_rejected() {
+ // FIN|TEXT, MASK bit set, len=0, + 4-byte mask key
+ final ByteBuffer f = ByteBuffer.allocate(2 + 4);
+ f.put((byte) 0x81);
+ f.put((byte) 0x80);
+ f.putInt(0x11223344);
+ f.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024);
+ assertThrows(WebSocketProtocolException.class, () -> d.decode(f));
+ }
+
+ @Test
+ void rsv_bits_without_extension_is_rejected() {
+ final ByteBuffer f = ByteBuffer.allocate(2);
+ f.put((byte) 0xC1); // FIN|RSV1|TEXT
+ f.put((byte) 0x00); // no mask, len=0
+ f.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024); // strict by default
+ final WebSocketProtocolException ex =
+ assertThrows(WebSocketProtocolException.class, () -> d.decode(f));
+ assertEquals(1002, ex.closeCode);
+ }
+
+ @Test
+ void partial_buffer_returns_false_and_does_not_consume() {
+ final ByteBuffer f = ByteBuffer.allocate(2);
+ f.put((byte) 0x81);
+ f.put((byte) 0x7E); // says 126 (extended), but no length bytes present
+ f.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024);
+ final int pos = f.position();
+ assertFalse(d.decode(f));
+ assertEquals(pos, f.position(), "decoder must reset position on incomplete frame");
+ }
+
+ @Test
+ void negative_127_length_throws() {
+ final ByteBuffer f = ByteBuffer.allocate(2 + 8);
+ f.put((byte) 0x82);
+ f.put((byte) 127);
+ f.putLong(-1L);
+ f.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024);
+ assertThrows(WebSocketProtocolException.class, () -> d.decode(f));
+ }
+
+ @Test
+ void frame_too_large_throws() {
+ final int len = 2000;
+ final ByteBuffer f = ByteBuffer.allocate(2 + 2 + len);
+ f.put((byte) 0x82);
+ f.put((byte) 126);
+ f.putShort((short) len);
+ f.put(new byte[len]);
+ f.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024); // max frame size smaller than len
+ assertThrows(WebSocketProtocolException.class, () -> d.decode(f));
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameWriterTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameWriterTest.java
new file mode 100644
index 0000000000..80c82b7b4f
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameWriterTest.java
@@ -0,0 +1,188 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.core.frame;
+
+import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.RSV1;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
+
+class FrameWriterTest {
+
+ private static class Parsed {
+ int b0;
+ int b1;
+ int opcode;
+ boolean fin;
+ boolean mask;
+ long len;
+ final byte[] maskKey = new byte[4];
+ int headerLen;
+ ByteBuffer payloadSlice;
+ }
+
+ private static Parsed parse(final ByteBuffer frame) {
+ final ByteBuffer frameCopy = frame.asReadOnlyBuffer();
+ final Parsed r = new Parsed();
+ r.b0 = frameCopy.get() & 0xFF;
+ r.fin = (r.b0 & 0x80) != 0;
+ r.opcode = r.b0 & 0x0F;
+
+ r.b1 = frameCopy.get() & 0xFF;
+ r.mask = (r.b1 & 0x80) != 0;
+ final int low = r.b1 & 0x7F;
+ if (low <= 125) {
+ r.len = low;
+ } else if (low == 126) {
+ r.len = frameCopy.getShort() & 0xFFFF;
+ } else {
+ r.len = frameCopy.getLong();
+ }
+
+ if (r.mask) {
+ frameCopy.get(r.maskKey);
+ }
+ r.headerLen = frameCopy.position();
+ r.payloadSlice = frameCopy.slice();
+ return r;
+ }
+
+ private static byte[] unmask(final Parsed p) {
+ final byte[] out = new byte[(int) p.len];
+ for (int i = 0; i < out.length; i++) {
+ int b = p.payloadSlice.get(i) & 0xFF;
+ b ^= p.maskKey[i & 3] & 0xFF;
+ out[i] = (byte) b;
+ }
+ return out;
+ }
+
+ @Test
+ void text_small_masked_roundtrip() {
+ final WebSocketFrameWriter w = new WebSocketFrameWriter();
+ final ByteBuffer f = w.text("hello", true);
+ final Parsed p = parse(f);
+ assertTrue(p.fin);
+ assertEquals(FrameOpcode.TEXT, p.opcode);
+ assertTrue(p.mask, "client frame must be masked");
+ assertEquals(5, p.len);
+ assertArrayEquals("hello".getBytes(StandardCharsets.UTF_8), unmask(p));
+ }
+
+ @Test
+ void binary_len_126_masked_roundtrip() {
+ final byte[] payload = new byte[300];
+ for (int i = 0; i < payload.length; i++) {
+ payload[i] = (byte) (i & 0xFF);
+ }
+
+ final WebSocketFrameWriter w = new WebSocketFrameWriter();
+ final ByteBuffer f = w.binary(ByteBuffer.wrap(payload), true);
+
+ final Parsed p = parse(f);
+ assertTrue(p.mask);
+ assertEquals(FrameOpcode.BINARY, p.opcode);
+ assertEquals(300, p.len);
+ assertArrayEquals(payload, unmask(p));
+ }
+
+ @Test
+ void binary_len_127_masked_roundtrip() {
+ final int len = 70000;
+ final byte[] payload = new byte[len];
+ Arrays.fill(payload, (byte) 0xA5);
+
+ final WebSocketFrameWriter w = new WebSocketFrameWriter();
+ final ByteBuffer f = w.binary(ByteBuffer.wrap(payload), true);
+
+ final Parsed p = parse(f);
+ assertTrue(p.mask);
+ assertEquals(FrameOpcode.BINARY, p.opcode);
+ assertEquals(len, p.len);
+ assertArrayEquals(payload, unmask(p));
+ }
+
+ @Test
+ void rsv1_set_with_frameWithRSV() {
+ final WebSocketFrameWriter w = new WebSocketFrameWriter();
+ final ByteBuffer payload = StandardCharsets.UTF_8.encode("x");
+ // Use RSV1 bit
+ final ByteBuffer f = w.frameWithRSV(FrameOpcode.TEXT, payload, true, true, RSV1);
+ final Parsed p = parse(f);
+ assertTrue(p.fin);
+ assertEquals(FrameOpcode.TEXT, p.opcode);
+ assertTrue((p.b0 & RSV1) != 0, "RSV1 must be set");
+ assertArrayEquals("x".getBytes(StandardCharsets.UTF_8), unmask(p));
+ }
+
+ @Test
+ void close_frame_contains_code_and_reason() {
+ final WebSocketFrameWriter w = new WebSocketFrameWriter();
+ final ByteBuffer f = w.close(1000, "done");
+ final Parsed p = parse(f);
+ assertTrue(p.mask);
+ assertEquals(FrameOpcode.CLOSE, p.opcode);
+ assertTrue(p.len >= 2);
+
+ final byte[] raw = unmask(p);
+ final int code = (raw[0] & 0xFF) << 8 | raw[1] & 0xFF;
+ final String reason = new String(raw, 2, raw.length - 2, StandardCharsets.UTF_8);
+
+ assertEquals(1000, code);
+ assertEquals("done", reason);
+ }
+
+ @Test
+ void closeEcho_masks_and_preserves_payload() {
+ // Build a close payload manually
+ final byte[] reason = "bye".getBytes(StandardCharsets.UTF_8);
+ final ByteBuffer payload = ByteBuffer.allocate(2 + reason.length);
+ payload.put((byte) (1000 >>> 8));
+ payload.put((byte) (1000 & 0xFF));
+ payload.put(reason);
+ payload.flip();
+
+ final WebSocketFrameWriter w = new WebSocketFrameWriter();
+ final ByteBuffer f = w.closeEcho(payload);
+ final Parsed p = parse(f);
+
+ assertTrue(p.mask);
+ assertEquals(FrameOpcode.CLOSE, p.opcode);
+ assertEquals(2 + reason.length, p.len);
+
+ final byte[] got = unmask(p);
+ assertEquals(1000, (got[0] & 0xFF) << 8 | got[1] & 0xFF);
+ assertEquals("bye", new String(got, 2, got.length - 2, StandardCharsets.UTF_8));
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/message/CloseCodecTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/message/CloseCodecTest.java
new file mode 100644
index 0000000000..fe334adf38
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/message/CloseCodecTest.java
@@ -0,0 +1,87 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.core.message;
+
+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 java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+final class CloseCodecTest {
+
+ @Test
+ void readEmptyIs1005() {
+ final ByteBuffer empty = ByteBuffer.allocate(0);
+ assertEquals(1005, CloseCodec.readCloseCode(empty.asReadOnlyBuffer()));
+ assertEquals("", CloseCodec.readCloseReason(empty.asReadOnlyBuffer()));
+ }
+
+ @Test
+ void readCodeAndReason() {
+ final ByteBuffer payload = ByteBuffer.allocate(2 + 4);
+ payload.put((byte) 0x03).put((byte) 0xE8); // 1000
+ payload.put(StandardCharsets.UTF_8.encode("done"));
+ payload.flip();
+
+ // Use the SAME buffer so the position advances
+ final ByteBuffer buf = payload.asReadOnlyBuffer();
+ assertEquals(1000, CloseCodec.readCloseCode(buf)); // advances position by 2
+ assertEquals("done", CloseCodec.readCloseReason(buf)); // reads remaining bytes only
+ }
+
+ @Test
+ void validateCloseCodes() {
+ assertTrue(CloseCodec.isValidToSend(1000));
+ assertTrue(CloseCodec.isValidToReceive(1000));
+ assertTrue(CloseCodec.isValidToSend(3000));
+ assertTrue(CloseCodec.isValidToReceive(3000));
+
+ assertFalse(CloseCodec.isValidToSend(1005));
+ assertFalse(CloseCodec.isValidToReceive(1005));
+ assertFalse(CloseCodec.isValidToSend(1006));
+ assertFalse(CloseCodec.isValidToReceive(1006));
+ assertFalse(CloseCodec.isValidToSend(1015));
+ assertFalse(CloseCodec.isValidToReceive(1015));
+
+ assertFalse(CloseCodec.isValidToSend(2000));
+ assertFalse(CloseCodec.isValidToReceive(2000));
+ }
+
+ @Test
+ void truncateReasonUtf8_capsAt123Bytes() {
+ final StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < 130; i++) {
+ sb.append('a');
+ }
+ final String truncated = CloseCodec.truncateReasonUtf8(sb.toString());
+ assertEquals(123, truncated.getBytes(StandardCharsets.UTF_8).length);
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java
new file mode 100644
index 0000000000..cb621c16aa
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java
@@ -0,0 +1,126 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.example;
+
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hc.client5.http.websocket.api.WebSocket;
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.client5.http.websocket.client.CloseableWebSocketClient;
+import org.apache.hc.client5.http.websocket.client.WebSocketClientBuilder;
+import org.apache.hc.core5.util.TimeValue;
+import org.apache.hc.core5.util.Timeout;
+
+public final class WebSocketEchoClient {
+
+ public static void main(final String[] args) throws Exception {
+ final URI uri = URI.create(args.length > 0 ? args[0] : "ws://localhost:8080/echo");
+ final CountDownLatch done = new CountDownLatch(1);
+
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .enablePerMessageDeflate(true)
+ .offerServerNoContextTakeover(true)
+ .offerClientNoContextTakeover(true)
+ .offerClientMaxWindowBits(15)
+ .setCloseWaitTimeout(Timeout.ofMilliseconds(200))
+ .build();
+
+ try (final CloseableWebSocketClient client = WebSocketClientBuilder.create()
+ .defaultConfig(cfg)
+ .build()) {
+
+ System.out.println("[TEST] connecting: " + uri);
+ client.start();
+
+ client.connect(uri, new WebSocketListener() {
+ private WebSocket ws;
+
+ @Override
+ public void onOpen(final WebSocket ws) {
+ this.ws = ws;
+ System.out.println("[TEST] open: " + uri);
+
+ final String prefix = "hello from hc5 WS @ " + Instant.now() + " — ";
+ final StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < 256; i++) {
+ sb.append(prefix);
+ }
+ final String msg = sb.toString();
+
+ ws.sendText(msg, true);
+ System.out.println("[TEST] sent (chars=" + msg.length() + ")");
+ }
+
+ @Override
+ public void onText(final CharBuffer text, final boolean last) {
+ final int len = text.length();
+ final CharSequence preview = len > 120 ? text.subSequence(0, 120) + "…" : text;
+ System.out.println("[TEST] text (chars=" + len + "): " + preview);
+ ws.close(1000, "done");
+ }
+
+ @Override
+ public void onPong(final ByteBuffer payload) {
+ System.out.println("[TEST] pong: " + StandardCharsets.UTF_8.decode(payload));
+ }
+
+ @Override
+ public void onClose(final int code, final String reason) {
+ System.out.println("[TEST] close: " + code + " " + reason);
+ done.countDown();
+ }
+
+ @Override
+ public void onError(final Throwable ex) {
+ ex.printStackTrace(System.err);
+ done.countDown();
+ }
+ }, cfg).exceptionally(ex -> {
+ done.countDown();
+ return null;
+ });
+
+ if (!done.await(12, TimeUnit.SECONDS)) {
+ System.err.println("[TEST] Timed out waiting for echo/close");
+ System.exit(1);
+ }
+
+ // Tidy shutdown: ask for shutdown, then wait briefly for the reactor to stop.
+ // Try-with-resources will still call close(GRACEFUL) at the end.
+ client.initiateShutdown();
+ client.awaitShutdown(TimeValue.ofSeconds(2));
+ }
+ }
+}
+
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java
new file mode 100644
index 0000000000..d592ce6b87
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java
@@ -0,0 +1,118 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.example;
+
+import java.nio.ByteBuffer;
+
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.eclipse.jetty.websocket.api.Session;
+import org.eclipse.jetty.websocket.api.WebSocketAdapter;
+import org.eclipse.jetty.websocket.servlet.WebSocketServlet;
+import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
+
+/**
+ * WebSocketEchoServer
+ *
+ * A tiny embedded Jetty WebSocket server that echoes back any TEXT or BINARY message
+ * it receives. This is intended for local development and interoperability testing of
+ * {@code WebSocketClient} and is not production hardened.
+ *
+ * Features
+ *
+ * - HTTP upgrade to RFC 6455 WebSocket on path {@code /echo}
+ * - Echoes TEXT and BINARY frames
+ * - Compatible with permessage-deflate (RFC 7692); Jetty will negotiate it if offered
+ *
+ *
+ * Usage
+ *
+ * # Default port 8080
+ * java -cp ... org.apache.hc.client5.http.websocket.example.WebSocketEchoServer
+ *
+ * # Custom port
+ * java -cp ... org.apache.hc.client5.http.websocket.example.WebSocketEchoServer 9090
+ *
+ *
+ * Once started, the server listens on {@code ws://localhost:<port>/echo}.
+ *
+ * Notes
+ *
+ * - If the port is already in use, Jetty will fail to start with {@code BindException}.
+ * - Idle timeout is set to 30 seconds for simplicity.
+ *
+ */
+public final class WebSocketEchoServer {
+
+ public static void main(final String[] args) throws Exception {
+ final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080;
+
+ final Server server = new Server(port);
+ final ServletContextHandler ctx = new ServletContextHandler(ServletContextHandler.SESSIONS);
+ ctx.setContextPath("/");
+ server.setHandler(ctx);
+
+ ctx.addServlet(new ServletHolder(new EchoServlet()), "/echo");
+ server.start();
+ System.out.println("[WS-Server] up at ws://localhost:" + port + "/echo");
+ server.join();
+ }
+
+ /**
+ * Simple servlet that wires a Jetty WebSocket endpoint at {@code /echo}.
+ */
+ public static final class EchoServlet extends WebSocketServlet {
+ @Override
+ public void configure(final WebSocketServletFactory factory) {
+ factory.getPolicy().setIdleTimeout(30_000);
+ // Jetty will negotiate permessage-deflate automatically if supported.
+ factory.setCreator((req, resp) -> new EchoSocket());
+ }
+ }
+
+ /**
+ * Echoes back text and binary messages.
+ */
+ public static final class EchoSocket extends WebSocketAdapter {
+ @Override
+ public void onWebSocketText(final String msg) {
+ final Session s = getSession();
+ if (s != null && s.isOpen()) {
+ s.getRemote().sendString(msg, null);
+ }
+ }
+
+ @Override
+ public void onWebSocketBinary(final byte[] payload, final int off, final int len) {
+ final Session s = getSession();
+ if (s != null && s.isOpen()) {
+ s.getRemote().sendBytes(ByteBuffer.wrap(payload, off, len), null);
+ }
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java
new file mode 100644
index 0000000000..c5bb68da02
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java
@@ -0,0 +1,149 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.transport;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.ByteBuffer;
+
+import org.apache.hc.client5.http.websocket.core.exceptions.WebSocketProtocolException;
+import org.apache.hc.client5.http.websocket.core.frame.FrameOpcode;
+import org.junit.jupiter.api.Test;
+
+class WsDecoderTest {
+
+ @Test
+ void serverMaskedFrame_isRejected() {
+ // Build a minimal TEXT frame with MASK bit set (which servers MUST NOT set).
+ // 0x81 FIN|TEXT, 0x80 | 0 = mask + length 0, then 4-byte masking key.
+ final ByteBuffer buf = ByteBuffer.allocate(2 + 4);
+ buf.put((byte) 0x81);
+ buf.put((byte) 0x80); // MASK set, len=0
+ buf.putInt(0x11223344);
+ buf.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192);
+ assertThrows(WebSocketProtocolException.class, () -> d.decode(buf));
+ }
+
+ @Test
+ void controlFrame_fragmented_isRejected() {
+ final ByteBuffer buf = ByteBuffer.allocate(2);
+ buf.put((byte) 0x09); // FIN=0, PING
+ buf.put((byte) 0x00); // len=0
+ buf.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192);
+ assertThrows(WebSocketProtocolException.class, () -> d.decode(buf));
+ }
+
+ @Test
+ void controlFrame_tooLarge_isRejected() {
+ final ByteBuffer buf = ByteBuffer.allocate(4);
+ buf.put((byte) 0x89); // FIN=1, PING
+ buf.put((byte) 126); // len=126 (invalid for control frame)
+ buf.putShort((short) 126);
+ buf.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192);
+ assertThrows(WebSocketProtocolException.class, () -> d.decode(buf));
+ }
+
+ @Test
+ void rsvBitsWithoutExtensions_areRejected() {
+ final ByteBuffer buf = ByteBuffer.allocate(2);
+ buf.put((byte) 0xC1); // FIN=1, RSV1=1, TEXT
+ buf.put((byte) 0x00);
+ buf.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192);
+ assertThrows(WebSocketProtocolException.class, () -> d.decode(buf));
+ }
+
+ @Test
+ void reservedOpcode_isRejected() {
+ final ByteBuffer buf = ByteBuffer.allocate(2);
+ buf.put((byte) 0x83); // FIN=1, opcode=3 (reserved)
+ buf.put((byte) 0x00);
+ buf.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192);
+ assertThrows(WebSocketProtocolException.class, () -> d.decode(buf));
+ }
+
+ @Test
+ void extendedLen_126_and_127_parse() {
+ // A FIN|BINARY with 126 length, len=300
+ final byte[] payload = new byte[300];
+ for (int i = 0; i < payload.length; i++) {
+ payload[i] = (byte) (i & 0xFF);
+ }
+
+ final ByteBuffer f126 = ByteBuffer.allocate(2 + 2 + payload.length);
+ f126.put((byte) 0x82); // FIN+BINARY
+ f126.put((byte) 126);
+ f126.putShort((short) payload.length);
+ f126.put(payload);
+ f126.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(4096);
+ assertTrue(d.decode(f126));
+ assertEquals(FrameOpcode.BINARY, d.opcode());
+ assertEquals(payload.length, d.payload().remaining());
+
+ // Now 127 with len=65540 (> 0xFFFF)
+ final int big = 65540;
+ final byte[] p2 = new byte[big];
+ final ByteBuffer f127 = ByteBuffer.allocate(2 + 8 + p2.length);
+ f127.put((byte) 0x82);
+ f127.put((byte) 127);
+ f127.putLong(big);
+ f127.put(p2);
+ f127.flip();
+
+ final WebSocketFrameDecoder d2 = new WebSocketFrameDecoder(big + 32);
+ assertTrue(d2.decode(f127));
+ assertEquals(big, d2.payload().remaining());
+ }
+
+ @Test
+ void partialBuffer_returnsFalse_and_consumesNothing() {
+ final ByteBuffer f = ByteBuffer.allocate(2);
+ f.put((byte) 0x81);
+ f.put((byte) 0x7E); // says 126, but no length bytes present
+ f.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024);
+ // Should mark/reset and return false; buffer remains at same position after call (no throw).
+ final int posBefore = f.position();
+ assertFalse(d.decode(f));
+ assertEquals(posBefore, f.position());
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundCompressionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundCompressionTest.java
new file mode 100644
index 0000000000..cbd695efe8
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundCompressionTest.java
@@ -0,0 +1,184 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.transport;
+
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Proxy;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
+import org.apache.hc.client5.http.websocket.api.WebSocket;
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.client5.http.websocket.core.extension.ExtensionChain;
+import org.apache.hc.client5.http.websocket.core.extension.PerMessageDeflate;
+import org.apache.hc.client5.http.websocket.core.frame.FrameOpcode;
+import org.apache.hc.core5.reactor.ProtocolIOSession;
+import org.junit.jupiter.api.Test;
+
+final class WsOutboundCompressionTest {
+
+ @Test
+ void outboundPmce_setsRsv1_and_roundTrips() throws Exception {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .setOutgoingChunkSize(64 * 1024)
+ .build();
+ final ExtensionChain chain = new ExtensionChain();
+ final PerMessageDeflate pmce = new PerMessageDeflate(true, true, true, null, null);
+ chain.add(pmce);
+
+ final WebSocketSessionState state = new WebSocketSessionState(dummySession(), new WebSocketListener() {
+ }, cfg, chain);
+ final WebSocketOutbound out = new WebSocketOutbound(state);
+ final WebSocket ws = out.facade();
+
+ final String text = "hello hello hello hello hello";
+ assertTrue(ws.sendText(text, true));
+
+ final WebSocketOutbound.OutFrame f = state.dataOutbound.poll();
+ assertNotNull(f);
+
+ final Frame frame = parseFrame(f.buf.asReadOnlyBuffer());
+ assertEquals(FrameOpcode.TEXT, frame.opcode);
+ assertTrue(frame.rsv1);
+ assertTrue(frame.masked);
+
+ final byte[] decoded = pmce.newDecoder().decode(frame.payload);
+ assertArrayEquals(text.getBytes(StandardCharsets.UTF_8), decoded);
+
+ release(state, f);
+ }
+
+ @Test
+ void outboundPmce_rsv1_onlyOnFirstFragment() {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .build();
+ final ExtensionChain chain = new ExtensionChain();
+ chain.add(new PerMessageDeflate(true, true, true, null, null));
+
+ final WebSocketSessionState state = new WebSocketSessionState(dummySession(), new WebSocketListener() {
+ }, cfg, chain);
+ final WebSocketOutbound out = new WebSocketOutbound(state);
+ final WebSocket ws = out.facade();
+
+ assertTrue(ws.sendTextBatch(Arrays.asList("alpha", "beta"), true));
+
+ final WebSocketOutbound.OutFrame first = state.dataOutbound.poll();
+ final WebSocketOutbound.OutFrame second = state.dataOutbound.poll();
+ assertNotNull(first);
+ assertNotNull(second);
+
+ final Frame f1 = parseFrame(first.buf.asReadOnlyBuffer());
+ final Frame f2 = parseFrame(second.buf.asReadOnlyBuffer());
+
+ assertEquals(FrameOpcode.TEXT, f1.opcode);
+ assertTrue(f1.rsv1);
+ assertEquals(FrameOpcode.CONT, f2.opcode);
+ assertFalse(f2.rsv1);
+
+ release(state, first);
+ release(state, second);
+ }
+
+ private static void release(final WebSocketSessionState state, final WebSocketOutbound.OutFrame f) {
+ if (f.pooled) {
+ state.bufferPool.release(f.buf);
+ }
+ }
+
+ private static ProtocolIOSession dummySession() {
+ return (ProtocolIOSession) Proxy.newProxyInstance(
+ ProtocolIOSession.class.getClassLoader(),
+ new Class>[]{ProtocolIOSession.class},
+ (proxy, method, args) -> {
+ final Class> rt = method.getReturnType();
+ if (rt == void.class) {
+ return null;
+ }
+ if (rt == boolean.class) {
+ return false;
+ }
+ if (rt == int.class) {
+ return 0;
+ }
+ if (rt == long.class) {
+ return 0L;
+ }
+ if (rt == float.class) {
+ return 0f;
+ }
+ if (rt == double.class) {
+ return 0d;
+ }
+ return null;
+ });
+ }
+
+ private static Frame parseFrame(final ByteBuffer buf) {
+ final int b0 = buf.get() & 0xFF;
+ final int b1 = buf.get() & 0xFF;
+ long len = b1 & 0x7F;
+ if (len == 126) {
+ len = buf.getShort() & 0xFFFF;
+ } else if (len == 127) {
+ len = buf.getLong();
+ }
+ final boolean masked = (b1 & 0x80) != 0;
+ final byte[] mask = masked ? new byte[4] : null;
+ if (mask != null) {
+ buf.get(mask);
+ }
+ final byte[] payload = new byte[(int) len];
+ buf.get(payload);
+ if (mask != null) {
+ for (int i = 0; i < payload.length; i++) {
+ payload[i] = (byte) (payload[i] ^ mask[i & 3]);
+ }
+ }
+ return new Frame(b0, masked, payload);
+ }
+
+ private static final class Frame {
+ final int opcode;
+ final boolean rsv1;
+ final boolean masked;
+ final byte[] payload;
+
+ Frame(final int b0, final boolean masked, final byte[] payload) {
+ this.opcode = b0 & 0x0F;
+ this.rsv1 = (b0 & 0x40) != 0;
+ this.masked = masked;
+ this.payload = payload;
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/resources/log4j2.xml b/httpclient5-websocket/src/test/resources/log4j2.xml
new file mode 100644
index 0000000000..ce9e796abd
--- /dev/null
+++ b/httpclient5-websocket/src/test/resources/log4j2.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pom.xml b/pom.xml
index 1a0d7291c6..0ceab71195 100644
--- a/pom.xml
+++ b/pom.xml
@@ -85,6 +85,7 @@
1.58.0
1.26.2
2.9.3
+ 9.4.54.v20240208
@@ -135,6 +136,11 @@
httpclient5-sse
${project.version}
+
+ org.apache.httpcomponents.client5
+ httpclient5-websocket
+ ${project.version}
+
org.slf4j
slf4j-api
@@ -265,6 +271,22 @@
caffeine
${caffeine.version}
+
+ org.eclipse.jetty
+ jetty-servlet
+ ${jetty.version}
+
+
+ org.eclipse.jetty.websocket
+ websocket-server
+ ${jetty.version}
+
+
+ org.eclipse.jetty
+ jetty-server
+ ${jetty.version}
+ test
+
@@ -273,6 +295,7 @@
httpclient5-sse
httpclient5-observation
httpclient5-fluent
+ httpclient5-websocket
httpclient5-cache
httpclient5-testing
@@ -495,6 +518,10 @@
Apache HttpClient SSE
org.apache.hc.client5.http.sse*
+
+ Apache HttpClient SSE
+ org.apache.hc.client5.http.websocket*
+
From c151392e3c95710610a211e735005883b244c593 Mon Sep 17 00:00:00 2001
From: Arturo Bernal
Date: Wed, 4 Feb 2026 17:42:16 +0100
Subject: [PATCH 2/2] Move logic to the core.
---
httpclient5-websocket/pom.xml | 4 +
.../client5/http/websocket/api/WebSocket.java | 9 +
.../websocket/api/WebSocketClientConfig.java | 4 +
.../http/websocket/api/WebSocketListener.java | 4 +
.../client/CloseableWebSocketClient.java | 7 +-
.../websocket/client/WebSocketClient.java | 3 +
.../client/WebSocketClientBuilder.java | 13 +-
.../websocket/client/WebSocketClients.java | 3 +
.../impl/protocol/Http1UpgradeProtocol.java | 4 +-
.../WebSocketProtocolException.java | 41 ----
.../core/exceptions/package-info.java | 36 ----
.../core/extension/ExtensionChain.java | 135 ------------
.../core/extension/PerMessageDeflate.java | 195 ------------------
.../extension/WebSocketExtensionChain.java | 80 -------
.../core/extension/package-info.java | 36 ----
.../websocket/core/frame/FrameHeaderBits.java | 49 -----
.../websocket/core/frame/FrameOpcode.java | 88 --------
.../core/frame/WebSocketFrameWriter.java | 189 -----------------
.../websocket/core/frame/package-info.java | 36 ----
.../websocket/core/message/CloseCodec.java | 190 -----------------
.../websocket/core/message/package-info.java | 36 ----
.../http/websocket/core/package-info.java | 36 ----
.../websocket/core/util/ByteBufferPool.java | 127 ------------
.../websocket/core/util/package-info.java | 36 ----
.../client5/http/websocket/package-info.java | 2 +-
.../transport/WebSocketFrameDecoder.java | 4 +-
.../websocket/transport/WebSocketInbound.java | 6 +-
.../transport/WebSocketIoHandler.java | 2 +-
.../transport/WebSocketOutbound.java | 6 +-
.../transport/WebSocketSessionState.java | 6 +-
.../transport/WebSocketUpgrader.java | 2 +-
.../Http1UpgradeProtocolExtensionTest.java | 4 +-
.../core/extension/ExtensionChainTest.java | 50 -----
.../core/extension/MessageDeflateTest.java | 90 --------
.../websocket/core/frame/FrameReaderTest.java | 184 -----------------
.../websocket/core/frame/FrameWriterTest.java | 188 -----------------
.../core/message/CloseCodecTest.java | 87 --------
.../example/WebSocketEchoClient.java | 5 +-
.../example/WebSocketEchoServer.java | 134 ++++++------
.../websocket/transport/WsDecoderTest.java | 4 +-
.../transport/WsOutboundCompressionTest.java | 6 +-
pom.xml | 7 +-
42 files changed, 144 insertions(+), 2004 deletions(-)
delete mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/WebSocketProtocolException.java
delete mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/package-info.java
delete mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChain.java
delete mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/PerMessageDeflate.java
delete mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/WebSocketExtensionChain.java
delete mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/package-info.java
delete mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameHeaderBits.java
delete mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameOpcode.java
delete mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/WebSocketFrameWriter.java
delete mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/package-info.java
delete mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/CloseCodec.java
delete mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/package-info.java
delete mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/package-info.java
delete mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/ByteBufferPool.java
delete mode 100644 httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/package-info.java
delete mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChainTest.java
delete mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/MessageDeflateTest.java
delete mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameReaderTest.java
delete mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameWriterTest.java
delete mode 100644 httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/message/CloseCodecTest.java
diff --git a/httpclient5-websocket/pom.xml b/httpclient5-websocket/pom.xml
index bd904b0e9c..f2a099cb09 100644
--- a/httpclient5-websocket/pom.xml
+++ b/httpclient5-websocket/pom.xml
@@ -49,6 +49,10 @@
org.apache.httpcomponents.client5
httpclient5-cache
+
+ org.apache.httpcomponents.core5
+ httpcore5-websocket
+
org.slf4j
slf4j-api
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java
index c235ffc9a0..10a72aa4ec 100644
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java
@@ -37,6 +37,15 @@
* invoked from arbitrary application threads. Inbound events are delivered
* to the associated {@link WebSocketListener}.
*
+ * Outbound calls return {@code true} when the frame has been accepted for
+ * transmission. They do not indicate that the peer has received or processed
+ * the message. Applications that require acknowledgements must implement them
+ * at the protocol layer.
+ *
+ * The close handshake follows RFC 6455. Applications should call
+ * {@link #close(int, String)} and wait for the {@link WebSocketListener#onClose(int, String)}
+ * callback to consider the connection terminated.
+ *
* @since 5.7
*/
public interface WebSocket {
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java
index 7875a5584c..ed938a2f71 100644
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java
@@ -41,6 +41,10 @@
* fragmentation behaviour, buffer pooling and optional automatic
* responses to PING frames.
*
+ * Unless explicitly overridden, reasonable defaults are selected for
+ * desktop and server environments. For mobile or memory-constrained
+ * deployments, consider adjusting buffer sizes and queue limits.
+ *
* @since 5.7
*/
public final class WebSocketClientConfig {
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java
index ca621672c2..c2ad5ca393 100644
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java
@@ -35,6 +35,10 @@
* Implementations should be fast and non-blocking because callbacks
* are normally invoked on I/O dispatcher threads.
*
+ * Exceptions thrown by callbacks are treated as errors and may result
+ * in the connection closing. Implementations should handle their own
+ * failures and avoid throwing unless they intend to abort the session.
+ *
* @since 5.7
*/
public interface WebSocketListener {
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.java
index 9209d8a552..622f951238 100644
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.java
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.java
@@ -45,10 +45,13 @@
/**
* Public WebSocket client API mirroring {@code CloseableHttpAsyncClient}'s shape.
*
- * Subclasses provide the actual connect implementation in {@link #doConnect(URI, WebSocketListener, WebSocketClientConfig, HttpContext)}.
+ *
Subclasses provide the actual connect implementation in
+ * {@link #doConnect(URI, WebSocketListener, WebSocketClientConfig, HttpContext)}.
* Overloads of {@code connect(...)} funnel into that single method.
*
- * This type is a {@link ModalCloseable}; use {@link #close(CloseMode)} to select graceful or immediate shutdown.
+ * This type is a {@link ModalCloseable}; use {@link #close(CloseMode)} to select
+ * graceful or immediate shutdown. A graceful close allows in-flight I/O to finish,
+ * while immediate close aborts active operations.
*
* @since 5.7
*/
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.java
index 938c1eae81..cbe660179a 100644
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.java
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.java
@@ -38,6 +38,9 @@
* Client for establishing WebSocket connections using the underlying
* asynchronous HttpClient infrastructure.
*
+ * This interface represents the minimal contract for initiating
+ * WebSocket handshakes. Implementations are expected to be thread-safe.
+ *
* @since 5.7
*/
public interface WebSocketClient {
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java
index f8f0e32209..8f5645e6c4 100644
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java
@@ -64,17 +64,19 @@
/**
* Builder for {@link CloseableWebSocketClient} instances.
- *
- * This builder assembles a WebSocket client on top of the asynchronous
+ *
+ *
This builder assembles a WebSocket client on top of the asynchronous
* HTTP/1.1 requester and connection pool infrastructure provided by
* HttpComponents Core. Unless otherwise specified, sensible defaults
- * are used for all components.
- *
+ * are used for all components.
*
* The resulting {@link CloseableWebSocketClient} manages its own I/O
* reactor and connection pool and must be {@link java.io.Closeable#close()
* closed} when no longer needed.
*
+ * Builders are mutable and not thread-safe. Configure the instance
+ * on a single thread and then call {@link #build()}.
+ *
* @since 5.7
*/
public final class WebSocketClientBuilder {
@@ -430,7 +432,8 @@ public CloseableWebSocketClient build() {
tls,
handshakeTimeout,
metricsListener,
- selector
+ selector,
+ 0
);
final ThreadFactory tf = threadFactory != null
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java
index 99bb30dc8d..e7bcb91b7b 100644
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java
@@ -35,6 +35,9 @@
* scenarios. For advanced configuration use
* {@link WebSocketClientBuilder} directly.
*
+ * Clients created by these helpers own their I/O resources and must be
+ * closed when no longer needed.
+ *
* @since 5.7
*/
public final class WebSocketClients {
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.java
index 4ad2c5a513..a1a52ddfed 100644
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.java
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.java
@@ -43,8 +43,8 @@
import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
import org.apache.hc.client5.http.websocket.api.WebSocketListener;
import org.apache.hc.client5.http.websocket.client.impl.connector.WebSocketEndpointConnector;
-import org.apache.hc.client5.http.websocket.core.extension.ExtensionChain;
-import org.apache.hc.client5.http.websocket.core.extension.PerMessageDeflate;
+import org.apache.hc.core5.websocket.extension.ExtensionChain;
+import org.apache.hc.core5.websocket.extension.PerMessageDeflate;
import org.apache.hc.client5.http.websocket.transport.WebSocketUpgrader;
import org.apache.hc.core5.annotation.Internal;
import org.apache.hc.core5.concurrent.FutureCallback;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/WebSocketProtocolException.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/WebSocketProtocolException.java
deleted file mode 100644
index c1af69a9a6..0000000000
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/WebSocketProtocolException.java
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-package org.apache.hc.client5.http.websocket.core.exceptions;
-
-
-import org.apache.hc.core5.annotation.Internal;
-
-@Internal
-public final class WebSocketProtocolException extends RuntimeException {
-
- public final int closeCode;
-
- public WebSocketProtocolException(final int closeCode, final String message) {
- super(message);
- this.closeCode = closeCode;
- }
-}
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/package-info.java
deleted file mode 100644
index 71bd5b4983..0000000000
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/exceptions/package-info.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-
-/**
- * Message-level helpers and codecs.
- *
- * Utilities for parsing and validating message semantics (e.g., CLOSE
- * status code and reason handling).
- *
- * @since 5.7
- */
-package org.apache.hc.client5.http.websocket.core.exceptions;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChain.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChain.java
deleted file mode 100644
index 69823a5b8a..0000000000
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChain.java
+++ /dev/null
@@ -1,135 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-package org.apache.hc.client5.http.websocket.core.extension;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import org.apache.hc.core5.annotation.Internal;
-
-/**
- * Simple single-step chain; if multiple extensions are added they are applied in order.
- * Only the FIRST extension can contribute the RSV bit (RSV1 in practice).
- */
-@Internal
-public final class ExtensionChain {
- private final List exts = new ArrayList<>();
-
- public void add(final WebSocketExtensionChain e) {
- if (e != null) {
- exts.add(e);
- }
- }
-
- public boolean isEmpty() {
- return exts.isEmpty();
- }
-
- /**
- * RSV bits used by the first extension in the chain (if any).
- * Only the first extension may contribute RSV bits.
- */
- public int rsvMask() {
- if (exts.isEmpty()) {
- return 0;
- }
- return exts.get(0).rsvMask();
- }
-
- /**
- * App-thread encoder chain.
- */
- public EncodeChain newEncodeChain() {
- final List encs = new ArrayList<>(exts.size());
- for (final WebSocketExtensionChain e : exts) {
- encs.add(e.newEncoder());
- }
- return new EncodeChain(encs);
- }
-
- /**
- * I/O-thread decoder chain.
- */
- public DecodeChain newDecodeChain() {
- final List decs = new ArrayList<>(exts.size());
- for (final WebSocketExtensionChain e : exts) {
- decs.add(e.newDecoder());
- }
- return new DecodeChain(decs);
- }
-
- // ----------------------
-
- public static final class EncodeChain {
- private final List encs;
-
- public EncodeChain(final List encs) {
- this.encs = encs;
- }
-
- /**
- * Encode one fragment through the chain; note RSV flag for the first extension.
- * Returns {@link WebSocketExtensionChain.Encoded}.
- */
- public WebSocketExtensionChain.Encoded encode(final byte[] data, final boolean first, final boolean fin) {
- if (encs.isEmpty()) {
- return new WebSocketExtensionChain.Encoded(data, false);
- }
- byte[] out = data;
- boolean setRsv1 = false;
- boolean firstExt = true;
- for (final WebSocketExtensionChain.Encoder e : encs) {
- final WebSocketExtensionChain.Encoded res = e.encode(out, first, fin);
- out = res.payload;
- if (first && firstExt && res.setRsvOnFirst) {
- setRsv1 = true;
- }
- firstExt = false;
- }
- return new WebSocketExtensionChain.Encoded(out, setRsv1);
- }
- }
-
- public static final class DecodeChain {
- private final List decs;
-
- public DecodeChain(final List decs) {
- this.decs = decs;
- }
-
- /**
- * Decode a full message (reverse order if stacking).
- */
- public byte[] decode(final byte[] data) throws Exception {
- byte[] out = data;
- for (int i = decs.size() - 1; i >= 0; i--) {
- out = decs.get(i).decode(out);
- }
- return out;
- }
- }
-}
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/PerMessageDeflate.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/PerMessageDeflate.java
deleted file mode 100644
index a8bef79ce7..0000000000
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/PerMessageDeflate.java
+++ /dev/null
@@ -1,195 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-package org.apache.hc.client5.http.websocket.core.extension;
-
-import java.io.ByteArrayOutputStream;
-import java.util.zip.Deflater;
-import java.util.zip.Inflater;
-
-import org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits;
-import org.apache.hc.core5.annotation.Internal;
-
-/**
- * Client-side permessage-deflate (RFC 7692).
- *
- * Window bit parameters are negotiated during the handshake:
- * {@code client_max_window_bits} limits the client's compression window (client->server),
- * while {@code server_max_window_bits} limits the server's compression window (server->client).
- * The decoder can accept any server window size (8..15). The encoder currently requires
- * {@code client_max_window_bits} to be 15, due to JDK Deflater limitations.
- */
-@Internal
-public final class PerMessageDeflate implements WebSocketExtensionChain {
- private static final byte[] TAIL = new byte[]{0x00, 0x00, (byte) 0xFF, (byte) 0xFF};
-
- private final boolean enabled;
- private final boolean serverNoContextTakeover;
- private final boolean clientNoContextTakeover;
- private final Integer clientMaxWindowBits; // negotiated or null
- private final Integer serverMaxWindowBits; // negotiated or null
-
- public PerMessageDeflate(final boolean enabled,
- final boolean serverNoContextTakeover,
- final boolean clientNoContextTakeover,
- final Integer clientMaxWindowBits,
- final Integer serverMaxWindowBits) {
- this.enabled = enabled;
- this.serverNoContextTakeover = serverNoContextTakeover;
- this.clientNoContextTakeover = clientNoContextTakeover;
- this.clientMaxWindowBits = clientMaxWindowBits;
- this.serverMaxWindowBits = serverMaxWindowBits;
- }
-
- @Override
- public int rsvMask() {
- return FrameHeaderBits.RSV1;
- }
-
- @Override
- public Encoder newEncoder() {
- if (!enabled) {
- return (data, first, fin) -> new Encoded(data, false);
- }
- return new Encoder() {
- private final Deflater def = new Deflater(Deflater.DEFAULT_COMPRESSION, true); // raw DEFLATE
-
- @Override
- public Encoded encode(final byte[] data, final boolean first, final boolean fin) {
- final byte[] out = first && fin
- ? compressMessage(data)
- : compressFragment(data, fin);
- // RSV1 on first compressed data frame only
- return new Encoded(out, first);
- }
-
- private byte[] compressMessage(final byte[] data) {
- return doDeflate(data, true, true, clientNoContextTakeover);
- }
-
- private byte[] compressFragment(final byte[] data, final boolean fin) {
- return doDeflate(data, fin, true,fin && clientNoContextTakeover);
- }
-
- private byte[] doDeflate(final byte[] data,
- final boolean fin,
- final boolean stripTail,
- final boolean maybeReset) {
- if (data == null || data.length == 0) {
- if (fin && maybeReset) {
- def.reset();
- }
- return new byte[0];
- }
- def.setInput(data);
- final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, data.length / 2));
- final byte[] buf = new byte[8192];
- while (!def.needsInput()) {
- final int n = def.deflate(buf, 0, buf.length, Deflater.SYNC_FLUSH);
- if (n > 0) {
- out.write(buf, 0, n);
- } else {
- break;
- }
- }
- byte[] all = out.toByteArray();
- if (stripTail && all.length >= 4) {
- final int newLen = all.length - 4; // strip 00 00 FF FF
- if (newLen <= 0) {
- all = new byte[0];
- } else {
- final byte[] trimmed = new byte[newLen];
- System.arraycopy(all, 0, trimmed, 0, newLen);
- all = trimmed;
- }
- }
- if (fin && maybeReset) {
- def.reset();
- }
- return all;
- }
- };
- }
-
- @Override
- public Decoder newDecoder() {
- if (!enabled) {
- return payload -> payload;
- }
- return new Decoder() {
- private final Inflater inf = new Inflater(true);
-
- @Override
- public byte[] decode(final byte[] compressedMessage) throws Exception {
- final byte[] withTail;
- if (compressedMessage == null || compressedMessage.length == 0) {
- withTail = TAIL.clone();
- } else {
- withTail = new byte[compressedMessage.length + 4];
- System.arraycopy(compressedMessage, 0, withTail, 0, compressedMessage.length);
- System.arraycopy(TAIL, 0, withTail, compressedMessage.length, 4);
- }
-
- inf.setInput(withTail);
- final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, withTail.length * 2));
- final byte[] buf = new byte[8192];
- while (!inf.needsInput()) {
- final int n = inf.inflate(buf);
- if (n > 0) {
- out.write(buf, 0, n);
- } else {
- break;
- }
- }
- if (serverNoContextTakeover) {
- inf.reset();
- }
- return out.toByteArray();
- }
- };
- }
-
- // optional getters for logging/tests
- public boolean isEnabled() {
- return enabled;
- }
-
- public boolean isServerNoContextTakeover() {
- return serverNoContextTakeover;
- }
-
- public boolean isClientNoContextTakeover() {
- return clientNoContextTakeover;
- }
-
- public Integer getClientMaxWindowBits() {
- return clientMaxWindowBits;
- }
-
- public Integer getServerMaxWindowBits() {
- return serverMaxWindowBits;
- }
-}
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/WebSocketExtensionChain.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/WebSocketExtensionChain.java
deleted file mode 100644
index 977014f18d..0000000000
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/WebSocketExtensionChain.java
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-package org.apache.hc.client5.http.websocket.core.extension;
-
-import org.apache.hc.core5.annotation.Internal;
-
-/**
- * Generic extension hook for payload transform (e.g., permessage-deflate).
- * Implementations may return RSV mask (usually RSV1) and indicate whether
- * the first frame of a message should set RSV.
- */
-@Internal
-public interface WebSocketExtensionChain {
-
- /**
- * RSV bits this extension uses on the first data frame (e.g. 0x40 for RSV1).
- */
- int rsvMask();
-
- /**
- * Create a thread-confined encoder instance (app thread).
- */
- Encoder newEncoder();
-
- /**
- * Create a thread-confined decoder instance (I/O thread).
- */
- Decoder newDecoder();
-
- /**
- * Encoded fragment result.
- */
- final class Encoded {
- public final byte[] payload;
- public final boolean setRsvOnFirst;
-
- public Encoded(final byte[] payload, final boolean setRsvOnFirst) {
- this.payload = payload;
- this.setRsvOnFirst = setRsvOnFirst;
- }
- }
-
- interface Encoder {
- /**
- * Encode one fragment; return transformed payload and whether to set RSV on FIRST frame.
- */
- Encoded encode(byte[] data, boolean first, boolean fin);
- }
-
- interface Decoder {
- /**
- * Decode a full message produced with this extension.
- */
- byte[] decode(byte[] payload) throws Exception;
- }
-}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/package-info.java
deleted file mode 100644
index 4ad28d0cf4..0000000000
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/extension/package-info.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-
-/**
- * WebSocket extension SPI and implementations.
- *
- * Includes the generic {@code Extension} SPI, chaining support, and a
- * client-side permessage-deflate (RFC 7692) implementation.
- *
- * @since 5.7
- */
-package org.apache.hc.client5.http.websocket.core.extension;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameHeaderBits.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameHeaderBits.java
deleted file mode 100644
index 9e76108f55..0000000000
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameHeaderBits.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-package org.apache.hc.client5.http.websocket.core.frame;
-
-import org.apache.hc.core5.annotation.Internal;
-
-/**
- * WebSocket frame header bit masks (RFC 6455 §5.2).
- */
-@Internal
-public final class FrameHeaderBits {
- private FrameHeaderBits() {
- }
-
- // First header byte
- public static final int FIN = 0x80;
- public static final int RSV1 = 0x40;
- public static final int RSV2 = 0x20;
- public static final int RSV3 = 0x10;
- // low 4 bits (0x0F) are opcode
-
- // Second header byte
- public static final int MASK_BIT = 0x80; // client->server payload mask bit
- // low 7 bits (0x7F) are payload len indicator
-}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameOpcode.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameOpcode.java
deleted file mode 100644
index 524cffc33d..0000000000
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/FrameOpcode.java
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-package org.apache.hc.client5.http.websocket.core.frame;
-
-import org.apache.hc.core5.annotation.Internal;
-
-/**
- * RFC 6455 opcode constants + helpers.
- */
-@Internal
-public final class FrameOpcode {
- public static final int CONT = 0x0;
- public static final int TEXT = 0x1;
- public static final int BINARY = 0x2;
- public static final int CLOSE = 0x8;
- public static final int PING = 0x9;
- public static final int PONG = 0xA;
-
- private FrameOpcode() {
- }
-
- /**
- * Control frames have the high bit set in the low nibble (0x8–0xF).
- */
- public static boolean isControl(final int opcode) {
- return (opcode & 0x08) != 0;
- }
-
- /**
- * Data opcodes (not continuation).
- */
- public static boolean isData(final int opcode) {
- return opcode == TEXT || opcode == BINARY;
- }
-
- /**
- * Continuation opcode.
- */
- public static boolean isContinuation(final int opcode) {
- return opcode == CONT;
- }
-
- /**
- * Optional: human-readable name for debugging.
- */
- public static String name(final int opcode) {
- switch (opcode) {
- case CONT:
- return "CONT";
- case TEXT:
- return "TEXT";
- case BINARY:
- return "BINARY";
- case CLOSE:
- return "CLOSE";
- case PING:
- return "PING";
- case PONG:
- return "PONG";
- default:
- return "0x" + Integer.toHexString(opcode);
- }
- }
-}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/WebSocketFrameWriter.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/WebSocketFrameWriter.java
deleted file mode 100644
index 9992c67c08..0000000000
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/WebSocketFrameWriter.java
+++ /dev/null
@@ -1,189 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-package org.apache.hc.client5.http.websocket.core.frame;
-
-import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.FIN;
-import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.MASK_BIT;
-import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.RSV1;
-import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.RSV2;
-import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.RSV3;
-
-import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
-import java.nio.charset.StandardCharsets;
-import java.util.concurrent.ThreadLocalRandom;
-
-import org.apache.hc.client5.http.websocket.core.message.CloseCodec;
-import org.apache.hc.core5.annotation.Internal;
-
-/**
- * RFC 6455 frame writer with helpers to build into an existing target buffer.
- *
- * @since 5.7
- */
-@Internal
-public final class WebSocketFrameWriter {
-
- // -- Text/Binary -----------------------------------------------------------
-
- public ByteBuffer text(final CharSequence data, final boolean fin) {
- final ByteBuffer payload = data == null ? ByteBuffer.allocate(0)
- : StandardCharsets.UTF_8.encode(data.toString());
- // Client → server MUST be masked
- return frame(FrameOpcode.TEXT, payload, fin, true);
- }
-
- public ByteBuffer binary(final ByteBuffer data, final boolean fin) {
- final ByteBuffer payload = data == null ? ByteBuffer.allocate(0) : data.asReadOnlyBuffer();
- return frame(FrameOpcode.BINARY, payload, fin, true);
- }
-
- // -- Control frames (FIN=true, payload ≤ 125, never compressed) -----------
-
- public ByteBuffer ping(final ByteBuffer payloadOrNull) {
- final ByteBuffer p = payloadOrNull == null ? ByteBuffer.allocate(0) : payloadOrNull.asReadOnlyBuffer();
- if (p.remaining() > 125) {
- throw new IllegalArgumentException("PING payload > 125 bytes");
- }
- return frame(FrameOpcode.PING, p, true, true);
- }
-
- public ByteBuffer pong(final ByteBuffer payloadOrNull) {
- final ByteBuffer p = payloadOrNull == null ? ByteBuffer.allocate(0) : payloadOrNull.asReadOnlyBuffer();
- if (p.remaining() > 125) {
- throw new IllegalArgumentException("PONG payload > 125 bytes");
- }
- return frame(FrameOpcode.PONG, p, true, true);
- }
-
- public ByteBuffer close(final int code, final String reason) {
- if (!CloseCodec.isValidToSend(code)) {
- throw new IllegalArgumentException("Invalid close code to send: " + code);
- }
- final String safeReason = CloseCodec.truncateReasonUtf8(reason);
- final ByteBuffer reasonBuf = safeReason.isEmpty()
- ? ByteBuffer.allocate(0)
- : StandardCharsets.UTF_8.encode(safeReason);
-
- if (reasonBuf.remaining() > 123) {
- throw new IllegalArgumentException("Close reason too long (UTF-8 bytes > 123)");
- }
-
- final ByteBuffer p = ByteBuffer.allocate(2 + reasonBuf.remaining());
- p.put((byte) (code >> 8 & 0xFF));
- p.put((byte) (code & 0xFF));
- if (reasonBuf.hasRemaining()) {
- p.put(reasonBuf);
- }
- p.flip();
- return frame(FrameOpcode.CLOSE, p, true, true);
- }
-
- public ByteBuffer closeEcho(final ByteBuffer payload) {
- final ByteBuffer p = payload == null ? ByteBuffer.allocate(0) : payload.asReadOnlyBuffer();
- if (p.remaining() > 125) {
- throw new IllegalArgumentException("Close payload > 125 bytes");
- }
- return frame(FrameOpcode.CLOSE, p, true, true);
- }
-
- // -- Core framing ----------------------------------------------------------
-
- public ByteBuffer frame(final int opcode, final ByteBuffer payload, final boolean fin, final boolean mask) {
- return frameWithRSV(opcode, payload, fin, mask, 0);
- }
-
- public ByteBuffer frameWithRSV(final int opcode, final ByteBuffer payload, final boolean fin,
- final boolean mask, final int rsvBits) {
- final int len = payload == null ? 0 : payload.remaining();
- final int hdrExtra = len <= 125 ? 0 : len <= 0xFFFF ? 2 : 8;
- final int maskLen = mask ? 4 : 0;
- final ByteBuffer out = ByteBuffer.allocate(2 + hdrExtra + maskLen + len).order(ByteOrder.BIG_ENDIAN);
- frameIntoWithRSV(opcode, payload, fin, mask, rsvBits, out);
- out.flip();
- return out;
- }
-
- public ByteBuffer frameInto(final int opcode, final ByteBuffer payload, final boolean fin,
- final boolean mask, final ByteBuffer out) {
- return frameIntoWithRSV(opcode, payload, fin, mask, 0, out);
- }
-
- public ByteBuffer frameIntoWithRSV(final int opcode, final ByteBuffer payload, final boolean fin,
- final boolean mask, final int rsvBits, final ByteBuffer out) {
- final int len = payload == null ? 0 : payload.remaining();
-
- if (FrameOpcode.isControl(opcode)) {
- if (!fin) {
- throw new IllegalArgumentException("Control frames must not be fragmented (FIN=false)");
- }
- if (len > 125) {
- throw new IllegalArgumentException("Control frame payload > 125 bytes");
- }
- if ((rsvBits & (RSV1 | RSV2 | RSV3)) != 0) {
- throw new IllegalArgumentException("RSV bits must be 0 on control frames");
- }
- }
-
- final int finBit = fin ? FIN : 0;
- out.put((byte) (finBit | rsvBits & (RSV1 | RSV2 | RSV3) | opcode & 0x0F));
-
- if (len <= 125) {
- out.put((byte) ((mask ? MASK_BIT : 0) | len));
- } else if (len <= 0xFFFF) {
- out.put((byte) ((mask ? MASK_BIT : 0) | 126));
- out.putShort((short) len);
- } else {
- out.put((byte) ((mask ? MASK_BIT : 0) | 127));
- out.putLong(len & 0x7FFF_FFFF_FFFF_FFFFL);
- }
-
- int[] mkey = null;
- if (mask) {
- mkey = new int[]{rnd(), rnd(), rnd(), rnd()};
- out.put((byte) mkey[0]).put((byte) mkey[1]).put((byte) mkey[2]).put((byte) mkey[3]);
- }
-
- if (len > 0) {
- final ByteBuffer src = payload.asReadOnlyBuffer();
- int i = 0; // simpler, safer mask index
- while (src.hasRemaining()) {
- int b = src.get() & 0xFF;
- if (mask) {
- b ^= mkey[i & 3];
- i++;
- }
- out.put((byte) b);
- }
- }
- return out;
- }
-
- private static int rnd() {
- return ThreadLocalRandom.current().nextInt(256);
- }
-}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/package-info.java
deleted file mode 100644
index dcf358cf08..0000000000
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/frame/package-info.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-
-/**
- * Low-level WebSocket frame helpers.
- *
- * Opcode constants, header bit masks, and frame writer utilities aligned
- * with RFC 6455.
- *
- * @since 5.7
- */
-package org.apache.hc.client5.http.websocket.core.frame;
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/CloseCodec.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/CloseCodec.java
deleted file mode 100644
index d6e8951121..0000000000
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/CloseCodec.java
+++ /dev/null
@@ -1,190 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-package org.apache.hc.client5.http.websocket.core.message;
-
-import java.nio.ByteBuffer;
-import java.nio.charset.StandardCharsets;
-
-import org.apache.hc.core5.annotation.Internal;
-
-/**
- * Helpers for RFC6455 CLOSE parsing & validation.
- */
-@Internal
-public final class CloseCodec {
-
- private CloseCodec() {
- }
-
-
- /**
- * Reads the close status code from the payload buffer, if present.
- * Returns {@code 1005} (“no status code present”) when the payload
- * does not contain at least two bytes.
- */
- public static int readCloseCode(final ByteBuffer payloadRO) {
- if (payloadRO == null || payloadRO.remaining() < 2) {
- return 1005; // “no status code present”
- }
- final int b1 = payloadRO.get() & 0xFF;
- final int b2 = payloadRO.get() & 0xFF;
- return (b1 << 8) | b2;
- }
-
- /**
- * Reads the close reason from the remaining bytes of the payload
- * as UTF-8. Returns an empty string if there is no payload left.
- */
- public static String readCloseReason(final ByteBuffer payloadRO) {
- if (payloadRO == null || !payloadRO.hasRemaining()) {
- return "";
- }
- final ByteBuffer dup = payloadRO.slice();
- return StandardCharsets.UTF_8.decode(dup).toString();
- }
-
- // ---- RFC validation (sender & receiver) ---------------------------------
-
- /**
- * RFC 6455 §7.4.2: MUST NOT appear on the wire.
- */
- private static boolean isForbiddenOnWire(final int code) {
- return code == 1005 || code == 1006 || code == 1015;
- }
-
- /**
- * Codes defined by RFC 6455 to send (and likewise valid to receive).
- */
- private static boolean isRfcDefined(final int code) {
- switch (code) {
- case 1000: // normal
- case 1001: // going away
- case 1002: // protocol error
- case 1003: // unsupported data
- case 1007: // invalid payload data
- case 1008: // policy violation
- case 1009: // message too big
- case 1010: // mandatory extension
- case 1011: // internal error
- return true;
- default:
- return false;
- }
- }
-
- /**
- * Application/reserved range that may be sent by endpoints.
- */
- private static boolean isAppRange(final int code) {
- return code >= 3000 && code <= 4999;
- }
-
- /**
- * Validate a code we intend to PUT ON THE WIRE (sender-side).
- */
- public static boolean isValidToSend(final int code) {
- if (code < 0) {
- return false;
- }
- if (isForbiddenOnWire(code)) {
- return false;
- }
- return isRfcDefined(code) || isAppRange(code);
- }
-
- /**
- * Validate a code we PARSED FROM THE WIRE (receiver-side).
- */
- public static boolean isValidToReceive(final int code) {
- // 1005, 1006, 1015 must not appear on the wire
- if (isForbiddenOnWire(code)) {
- return false;
- }
- // Same allowed sets otherwise
- return isRfcDefined(code) || isAppRange(code);
- }
-
- // ---- Reason handling: max 123 bytes (2 bytes used by code) --------------
-
- /**
- * Returns a UTF-8 string truncated to ≤ 123 bytes, preserving code-points.
- * This ensures that a CLOSE frame payload (2-byte status code + reason)
- * never exceeds the 125-byte control frame limit.
- */
- public static String truncateReasonUtf8(final String reason) {
- if (reason == null || reason.isEmpty()) {
- return "";
- }
- final byte[] bytes = reason.getBytes(StandardCharsets.UTF_8);
- if (bytes.length <= 123) {
- return reason;
- }
- int i = 0;
- int byteCount = 0;
- while (i < reason.length()) {
- final int cp = reason.codePointAt(i);
- final int charCount = Character.charCount(cp);
- final int extra = new String(Character.toChars(cp))
- .getBytes(StandardCharsets.UTF_8).length;
- if (byteCount + extra > 123) {
- break;
- }
- byteCount += extra;
- i += charCount;
- }
- return reason.substring(0, i);
- }
-
- // ---- Encoding -----------------------------------------------------------
-
- /**
- * Encodes a close status code and reason into a payload suitable for a
- * CLOSE control frame:
- *
- *
- * payload[0] = high-byte of status code
- * payload[1] = low-byte of status code
- * payload[2..] = UTF-8 bytes of the (possibly truncated) reason
- *
- *
- * The reason is internally truncated to ≤ 123 UTF-8 bytes to ensure the
- * resulting payload never exceeds the 125-byte control frame limit.
- *
- * The caller is expected to have already validated the status code with
- * {@link #isValidToSend(int)}.
- */
- public static byte[] encode(final int statusCode, final String reason) {
- final String truncated = truncateReasonUtf8(reason);
- final byte[] reasonBytes = truncated.getBytes(StandardCharsets.UTF_8);
- // 2 bytes for the status code
- final byte[] payload = new byte[2 + reasonBytes.length];
- payload[0] = (byte) ((statusCode >>> 8) & 0xFF);
- payload[1] = (byte) (statusCode & 0xFF);
- System.arraycopy(reasonBytes, 0, payload, 2, reasonBytes.length);
- return payload;
- }
-}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/package-info.java
deleted file mode 100644
index 475f42c2e6..0000000000
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/message/package-info.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-
-/**
- * Message-level helpers and codecs.
- *
- *
Utilities for parsing and validating message semantics (e.g., CLOSE
- * status code and reason handling).
- *
- * @since 5.7
- */
-package org.apache.hc.client5.http.websocket.core.message;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/package-info.java
deleted file mode 100644
index 2860c787cd..0000000000
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/package-info.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-
-/**
- * Core WebSocket implementation utilities.
- *
- * Implementation detail packages live under {@code core}. These are not
- * part of the public API and may change without notice.
- *
- * @since 5.7
- */
-package org.apache.hc.client5.http.websocket.core;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/ByteBufferPool.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/ByteBufferPool.java
deleted file mode 100644
index 4c8a9281a2..0000000000
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/ByteBufferPool.java
+++ /dev/null
@@ -1,127 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-package org.apache.hc.client5.http.websocket.core.util;
-
-import java.nio.ByteBuffer;
-import java.util.concurrent.ConcurrentLinkedQueue;
-import java.util.concurrent.atomic.AtomicInteger;
-
-import org.apache.hc.core5.annotation.Internal;
-
-/**
- * Lock-free fixed-size ByteBuffer pool with a hard capacity limit.
- * Buffers are cleared before reuse. Non-matching capacities are dropped.
- *
- * @since 5.7
- */
-@Internal
-public final class ByteBufferPool {
-
- private final ConcurrentLinkedQueue pool = new ConcurrentLinkedQueue<>();
- private final AtomicInteger pooled = new AtomicInteger(0);
-
- private final int bufferSize;
- private final int maxCapacity;
- private final boolean direct;
-
- public ByteBufferPool(final int bufferSize, final int maxCapacity) {
- this(bufferSize, maxCapacity, false);
- }
-
- public ByteBufferPool(final int bufferSize, final int maxCapacity, final boolean direct) {
- if (bufferSize <= 0 || maxCapacity < 0) {
- throw new IllegalArgumentException("Invalid pool configuration");
- }
- this.bufferSize = bufferSize;
- this.maxCapacity = maxCapacity;
- this.direct = direct;
- }
-
- /**
- * Acquire a buffer or allocate a new one if the pool is empty.
- */
- public ByteBuffer acquire() {
- final ByteBuffer buf = pool.poll();
- if (buf != null) {
- pooled.decrementAndGet();
- buf.clear();
- return buf;
- }
- return direct ? ByteBuffer.allocateDirect(bufferSize) : ByteBuffer.allocate(bufferSize);
- }
-
- /**
- * Return a buffer to the pool iff it matches the configured capacity and there is room.
- */
- public void release(final ByteBuffer buffer) {
- if (buffer == null || buffer.capacity() != bufferSize) {
- return;
- }
- buffer.clear();
- for (;;) {
- final int n = pooled.get();
- if (n >= maxCapacity) {
- return;
- }
- if (pooled.compareAndSet(n, n + 1)) {
- pool.offer(buffer);
- return;
- }
- }
- }
-
- /**
- * Drain the pool.
- */
- public void clear() {
- while (pool.poll() != null) { /* drain */ }
- pooled.set(0);
- }
-
- /**
- * Size in bytes of pooled buffers.
- */
- public int bufferSize() {
- return bufferSize;
- }
-
- /**
- * Backwards-compatible accessor for callers expecting getBufferSize().
- */
- public int getBufferSize() {
- return bufferSize;
- }
-
- public int maxCapacity() {
- return maxCapacity;
- }
-
- public int pooledCount() {
- return pooled.get();
- }
-
-}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/package-info.java
deleted file mode 100644
index 168ca362e4..0000000000
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/core/util/package-info.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-
-/**
- * Message-level helpers and codecs.
- *
- * Utilities for parsing and validating message semantics (e.g., CLOSE
- * status code and reason handling).
- *
- * @since 5.7
- */
-package org.apache.hc.client5.http.websocket.core.util;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/package-info.java
index 70b0f5705a..f7b59da2f1 100644
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/package-info.java
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/package-info.java
@@ -65,7 +65,7 @@
* {@code closeWaitTimeout} controls how long the client will wait for the
* peer's close frame before the underlying connection is closed.
*
- * Classes in {@code org.apache.hc.client5.http.websocket.core} and
+ *
Classes in {@code org.apache.hc.core5.websocket} subpackages and
* {@code org.apache.hc.client5.http.websocket.transport} are internal
* implementation details and are not intended for direct use.
*
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketFrameDecoder.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketFrameDecoder.java
index 3153ed5bab..d552bad1ef 100644
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketFrameDecoder.java
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketFrameDecoder.java
@@ -28,8 +28,8 @@
import java.nio.ByteBuffer;
-import org.apache.hc.client5.http.websocket.core.exceptions.WebSocketProtocolException;
-import org.apache.hc.client5.http.websocket.core.frame.FrameOpcode;
+import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException;
+import org.apache.hc.core5.websocket.frame.FrameOpcode;
import org.apache.hc.core5.annotation.Internal;
@Internal
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java
index 12405a1266..8e24e00caa 100644
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java
@@ -35,9 +35,9 @@
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeoutException;
-import org.apache.hc.client5.http.websocket.core.exceptions.WebSocketProtocolException;
-import org.apache.hc.client5.http.websocket.core.frame.FrameOpcode;
-import org.apache.hc.client5.http.websocket.core.message.CloseCodec;
+import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException;
+import org.apache.hc.core5.websocket.frame.FrameOpcode;
+import org.apache.hc.core5.websocket.message.CloseCodec;
import org.apache.hc.core5.annotation.Internal;
import org.apache.hc.core5.io.CloseMode;
import org.apache.hc.core5.reactor.EventMask;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandler.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandler.java
index f45720c487..b50e4b7740 100644
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandler.java
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandler.java
@@ -32,7 +32,7 @@
import org.apache.hc.client5.http.websocket.api.WebSocket;
import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
import org.apache.hc.client5.http.websocket.api.WebSocketListener;
-import org.apache.hc.client5.http.websocket.core.extension.ExtensionChain;
+import org.apache.hc.core5.websocket.extension.ExtensionChain;
import org.apache.hc.core5.annotation.Internal;
import org.apache.hc.core5.http.nio.AsyncClientEndpoint;
import org.apache.hc.core5.http.nio.command.ShutdownCommand;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java
index 700833d68f..f928e848af 100644
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java
@@ -32,9 +32,9 @@
import java.util.concurrent.CompletableFuture;
import org.apache.hc.client5.http.websocket.api.WebSocket;
-import org.apache.hc.client5.http.websocket.core.extension.WebSocketExtensionChain;
-import org.apache.hc.client5.http.websocket.core.frame.FrameOpcode;
-import org.apache.hc.client5.http.websocket.core.message.CloseCodec;
+import org.apache.hc.core5.websocket.extension.WebSocketExtensionChain;
+import org.apache.hc.core5.websocket.frame.FrameOpcode;
+import org.apache.hc.core5.websocket.message.CloseCodec;
import org.apache.hc.core5.annotation.Internal;
import org.apache.hc.core5.io.CloseMode;
import org.apache.hc.core5.reactor.EventMask;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.java
index 0074825329..34614d5278 100644
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.java
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.java
@@ -33,9 +33,9 @@
import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
import org.apache.hc.client5.http.websocket.api.WebSocketListener;
-import org.apache.hc.client5.http.websocket.core.extension.ExtensionChain;
-import org.apache.hc.client5.http.websocket.core.frame.WebSocketFrameWriter;
-import org.apache.hc.client5.http.websocket.core.util.ByteBufferPool;
+import org.apache.hc.core5.websocket.extension.ExtensionChain;
+import org.apache.hc.core5.websocket.frame.WebSocketFrameWriter;
+import org.apache.hc.core5.websocket.util.ByteBufferPool;
import org.apache.hc.core5.annotation.Internal;
import org.apache.hc.core5.reactor.ProtocolIOSession;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgrader.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgrader.java
index c94f8473d7..08be8953ae 100644
--- a/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgrader.java
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgrader.java
@@ -29,7 +29,7 @@
import org.apache.hc.client5.http.websocket.api.WebSocket;
import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
import org.apache.hc.client5.http.websocket.api.WebSocketListener;
-import org.apache.hc.client5.http.websocket.core.extension.ExtensionChain;
+import org.apache.hc.core5.websocket.extension.ExtensionChain;
import org.apache.hc.core5.annotation.Internal;
import org.apache.hc.core5.concurrent.FutureCallback;
import org.apache.hc.core5.http.nio.AsyncClientEndpoint;
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocolExtensionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocolExtensionTest.java
index cf5d7fa7da..153a632cdd 100644
--- a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocolExtensionTest.java
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocolExtensionTest.java
@@ -31,8 +31,8 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
-import org.apache.hc.client5.http.websocket.core.extension.ExtensionChain;
-import org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits;
+import org.apache.hc.core5.websocket.extension.ExtensionChain;
+import org.apache.hc.core5.websocket.frame.FrameHeaderBits;
import org.junit.jupiter.api.Test;
final class Http1UpgradeProtocolExtensionTest {
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChainTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChainTest.java
deleted file mode 100644
index 39408db577..0000000000
--- a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/ExtensionChainTest.java
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-package org.apache.hc.client5.http.websocket.core.extension;
-
-import static org.junit.jupiter.api.Assertions.assertArrayEquals;
-
-import java.nio.charset.StandardCharsets;
-
-import org.junit.jupiter.api.Test;
-
-final class ExtensionChainTest {
-
- @Test
- void addAndUsePmce_decodeRoundTrip() throws Exception {
- final ExtensionChain chain = new ExtensionChain();
- final PerMessageDeflate pmce = new PerMessageDeflate(true, true, true, null, null);
- chain.add(pmce);
-
- final byte[] data = "compress me please".getBytes(StandardCharsets.UTF_8);
-
- final WebSocketExtensionChain.Encoded enc = pmce.newEncoder().encode(data, true, true);
- final byte[] back = chain.newDecodeChain().decode(enc.payload);
-
- assertArrayEquals(data, back);
- }
-}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/MessageDeflateTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/MessageDeflateTest.java
deleted file mode 100644
index 1436c98353..0000000000
--- a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/extension/MessageDeflateTest.java
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-package org.apache.hc.client5.http.websocket.core.extension;
-
-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.assertNotEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import java.nio.charset.StandardCharsets;
-
-import org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits;
-import org.junit.jupiter.api.Test;
-
-final class MessageDeflateTest {
-
- @Test
- void rsvMask_isRSV1() {
- final PerMessageDeflate pmce = new PerMessageDeflate(true, false, false, null, null);
- assertEquals(FrameHeaderBits.RSV1, pmce.rsvMask());
- }
-
- @Test
- void encode_setsRSVOnlyOnFirst() {
- final PerMessageDeflate pmce = new PerMessageDeflate(true, false, false, null, null);
- final WebSocketExtensionChain.Encoder enc = pmce.newEncoder();
-
- final byte[] data = "hello".getBytes(StandardCharsets.UTF_8);
-
- final WebSocketExtensionChain.Encoded first = enc.encode(data, true, false);
- final WebSocketExtensionChain.Encoded cont = enc.encode(data, false, true);
-
- assertTrue(first.setRsvOnFirst, "RSV on first fragment");
- assertFalse(cont.setRsvOnFirst, "no RSV on continuation");
- assertNotEquals(0, first.payload.length);
- assertNotEquals(0, cont.payload.length);
- }
-
- @Test
- void roundTrip_message() throws Exception {
- final PerMessageDeflate pmce = new PerMessageDeflate(true, true, true, null, null);
- final WebSocketExtensionChain.Encoder enc = pmce.newEncoder();
- final WebSocketExtensionChain.Decoder dec = pmce.newDecoder();
-
- final String s = "The quick brown fox jumps over the lazy dog. "
- + "The quick brown fox jumps over the lazy dog.";
- final byte[] plain = s.getBytes(StandardCharsets.UTF_8);
-
- // Single-frame message: first=true, fin=true
- final byte[] wire = enc.encode(plain, true, true).payload;
-
- assertTrue(wire.length > 0);
- assertFalse(endsWithTail(wire), "tail must be stripped on wire");
-
- final byte[] roundTrip = dec.decode(wire);
- assertArrayEquals(plain, roundTrip);
- }
-
- private static boolean endsWithTail(final byte[] b) {
- if (b.length < 4) {
- return false;
- }
- return b[b.length - 4] == 0x00 && b[b.length - 3] == 0x00 && (b[b.length - 2] & 0xFF) == 0xFF && (b[b.length - 1] & 0xFF) == 0xFF;
- }
-}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameReaderTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameReaderTest.java
deleted file mode 100644
index cadb431eb1..0000000000
--- a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameReaderTest.java
+++ /dev/null
@@ -1,184 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-package org.apache.hc.client5.http.websocket.core.frame;
-
-
-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.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import java.nio.ByteBuffer;
-import java.nio.charset.StandardCharsets;
-import java.util.Arrays;
-
-import org.apache.hc.client5.http.websocket.core.exceptions.WebSocketProtocolException;
-import org.apache.hc.client5.http.websocket.transport.WebSocketFrameDecoder;
-import org.junit.jupiter.api.Test;
-
-class FrameReaderTest {
-
- private static ByteBuffer serverTextFrame(final String s) {
- final byte[] p = s.getBytes(StandardCharsets.UTF_8);
- final int len = p.length;
- final ByteBuffer buf;
- if (len <= 125) {
- buf = ByteBuffer.allocate(2 + len);
- buf.put((byte) 0x81); // FIN|TEXT
- buf.put((byte) len); // no MASK
- } else if (len <= 0xFFFF) {
- buf = ByteBuffer.allocate(2 + 2 + len);
- buf.put((byte) 0x81);
- buf.put((byte) 126);
- buf.putShort((short) len);
- } else {
- buf = ByteBuffer.allocate(2 + 8 + len);
- buf.put((byte) 0x81);
- buf.put((byte) 127);
- buf.putLong(len);
- }
- buf.put(p);
- buf.flip();
- return buf;
- }
-
- @Test
- void decode_small_text_unmasked() {
- final ByteBuffer f = serverTextFrame("hello");
- final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192);
- assertTrue(d.decode(f));
- assertEquals(FrameOpcode.TEXT, d.opcode());
- assertTrue(d.fin());
- assertFalse(d.rsv1());
- assertEquals("hello", StandardCharsets.UTF_8.decode(d.payload()).toString());
- }
-
- @Test
- void decode_extended_126_length() {
- final byte[] p = new byte[300];
- for (int i = 0; i < p.length; i++) {
- p[i] = (byte) (i & 0xFF);
- }
- final ByteBuffer f = ByteBuffer.allocate(2 + 2 + p.length);
- f.put((byte) 0x82); // FIN|BINARY
- f.put((byte) 126);
- f.putShort((short) p.length);
- f.put(p);
- f.flip();
-
- final WebSocketFrameDecoder d = new WebSocketFrameDecoder(4096);
- assertTrue(d.decode(f));
- assertEquals(FrameOpcode.BINARY, d.opcode());
- final ByteBuffer payload = d.payload();
- final byte[] got = new byte[p.length];
- payload.get(got);
- assertArrayEquals(p, got);
- }
-
- @Test
- void decode_extended_127_length() {
- final int len = 66000;
- final byte[] p = new byte[len];
- Arrays.fill(p, (byte) 0xAB);
- final ByteBuffer f = ByteBuffer.allocate(2 + 8 + len);
- f.put((byte) 0x82); // FIN|BINARY
- f.put((byte) 127);
- f.putLong(len);
- f.put(p);
- f.flip();
-
- final WebSocketFrameDecoder d = new WebSocketFrameDecoder(len + 64);
- assertTrue(d.decode(f));
- assertEquals(len, d.payload().remaining());
- }
-
- @Test
- void masked_server_frame_is_rejected() {
- // FIN|TEXT, MASK bit set, len=0, + 4-byte mask key
- final ByteBuffer f = ByteBuffer.allocate(2 + 4);
- f.put((byte) 0x81);
- f.put((byte) 0x80);
- f.putInt(0x11223344);
- f.flip();
-
- final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024);
- assertThrows(WebSocketProtocolException.class, () -> d.decode(f));
- }
-
- @Test
- void rsv_bits_without_extension_is_rejected() {
- final ByteBuffer f = ByteBuffer.allocate(2);
- f.put((byte) 0xC1); // FIN|RSV1|TEXT
- f.put((byte) 0x00); // no mask, len=0
- f.flip();
-
- final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024); // strict by default
- final WebSocketProtocolException ex =
- assertThrows(WebSocketProtocolException.class, () -> d.decode(f));
- assertEquals(1002, ex.closeCode);
- }
-
- @Test
- void partial_buffer_returns_false_and_does_not_consume() {
- final ByteBuffer f = ByteBuffer.allocate(2);
- f.put((byte) 0x81);
- f.put((byte) 0x7E); // says 126 (extended), but no length bytes present
- f.flip();
-
- final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024);
- final int pos = f.position();
- assertFalse(d.decode(f));
- assertEquals(pos, f.position(), "decoder must reset position on incomplete frame");
- }
-
- @Test
- void negative_127_length_throws() {
- final ByteBuffer f = ByteBuffer.allocate(2 + 8);
- f.put((byte) 0x82);
- f.put((byte) 127);
- f.putLong(-1L);
- f.flip();
-
- final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024);
- assertThrows(WebSocketProtocolException.class, () -> d.decode(f));
- }
-
- @Test
- void frame_too_large_throws() {
- final int len = 2000;
- final ByteBuffer f = ByteBuffer.allocate(2 + 2 + len);
- f.put((byte) 0x82);
- f.put((byte) 126);
- f.putShort((short) len);
- f.put(new byte[len]);
- f.flip();
-
- final WebSocketFrameDecoder d = new WebSocketFrameDecoder(1024); // max frame size smaller than len
- assertThrows(WebSocketProtocolException.class, () -> d.decode(f));
- }
-}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameWriterTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameWriterTest.java
deleted file mode 100644
index 80c82b7b4f..0000000000
--- a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/frame/FrameWriterTest.java
+++ /dev/null
@@ -1,188 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-package org.apache.hc.client5.http.websocket.core.frame;
-
-import static org.apache.hc.client5.http.websocket.core.frame.FrameHeaderBits.RSV1;
-import static org.junit.jupiter.api.Assertions.assertArrayEquals;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import org.junit.jupiter.api.Test;
-
-import java.nio.ByteBuffer;
-import java.nio.charset.StandardCharsets;
-import java.util.Arrays;
-
-
-class FrameWriterTest {
-
- private static class Parsed {
- int b0;
- int b1;
- int opcode;
- boolean fin;
- boolean mask;
- long len;
- final byte[] maskKey = new byte[4];
- int headerLen;
- ByteBuffer payloadSlice;
- }
-
- private static Parsed parse(final ByteBuffer frame) {
- final ByteBuffer frameCopy = frame.asReadOnlyBuffer();
- final Parsed r = new Parsed();
- r.b0 = frameCopy.get() & 0xFF;
- r.fin = (r.b0 & 0x80) != 0;
- r.opcode = r.b0 & 0x0F;
-
- r.b1 = frameCopy.get() & 0xFF;
- r.mask = (r.b1 & 0x80) != 0;
- final int low = r.b1 & 0x7F;
- if (low <= 125) {
- r.len = low;
- } else if (low == 126) {
- r.len = frameCopy.getShort() & 0xFFFF;
- } else {
- r.len = frameCopy.getLong();
- }
-
- if (r.mask) {
- frameCopy.get(r.maskKey);
- }
- r.headerLen = frameCopy.position();
- r.payloadSlice = frameCopy.slice();
- return r;
- }
-
- private static byte[] unmask(final Parsed p) {
- final byte[] out = new byte[(int) p.len];
- for (int i = 0; i < out.length; i++) {
- int b = p.payloadSlice.get(i) & 0xFF;
- b ^= p.maskKey[i & 3] & 0xFF;
- out[i] = (byte) b;
- }
- return out;
- }
-
- @Test
- void text_small_masked_roundtrip() {
- final WebSocketFrameWriter w = new WebSocketFrameWriter();
- final ByteBuffer f = w.text("hello", true);
- final Parsed p = parse(f);
- assertTrue(p.fin);
- assertEquals(FrameOpcode.TEXT, p.opcode);
- assertTrue(p.mask, "client frame must be masked");
- assertEquals(5, p.len);
- assertArrayEquals("hello".getBytes(StandardCharsets.UTF_8), unmask(p));
- }
-
- @Test
- void binary_len_126_masked_roundtrip() {
- final byte[] payload = new byte[300];
- for (int i = 0; i < payload.length; i++) {
- payload[i] = (byte) (i & 0xFF);
- }
-
- final WebSocketFrameWriter w = new WebSocketFrameWriter();
- final ByteBuffer f = w.binary(ByteBuffer.wrap(payload), true);
-
- final Parsed p = parse(f);
- assertTrue(p.mask);
- assertEquals(FrameOpcode.BINARY, p.opcode);
- assertEquals(300, p.len);
- assertArrayEquals(payload, unmask(p));
- }
-
- @Test
- void binary_len_127_masked_roundtrip() {
- final int len = 70000;
- final byte[] payload = new byte[len];
- Arrays.fill(payload, (byte) 0xA5);
-
- final WebSocketFrameWriter w = new WebSocketFrameWriter();
- final ByteBuffer f = w.binary(ByteBuffer.wrap(payload), true);
-
- final Parsed p = parse(f);
- assertTrue(p.mask);
- assertEquals(FrameOpcode.BINARY, p.opcode);
- assertEquals(len, p.len);
- assertArrayEquals(payload, unmask(p));
- }
-
- @Test
- void rsv1_set_with_frameWithRSV() {
- final WebSocketFrameWriter w = new WebSocketFrameWriter();
- final ByteBuffer payload = StandardCharsets.UTF_8.encode("x");
- // Use RSV1 bit
- final ByteBuffer f = w.frameWithRSV(FrameOpcode.TEXT, payload, true, true, RSV1);
- final Parsed p = parse(f);
- assertTrue(p.fin);
- assertEquals(FrameOpcode.TEXT, p.opcode);
- assertTrue((p.b0 & RSV1) != 0, "RSV1 must be set");
- assertArrayEquals("x".getBytes(StandardCharsets.UTF_8), unmask(p));
- }
-
- @Test
- void close_frame_contains_code_and_reason() {
- final WebSocketFrameWriter w = new WebSocketFrameWriter();
- final ByteBuffer f = w.close(1000, "done");
- final Parsed p = parse(f);
- assertTrue(p.mask);
- assertEquals(FrameOpcode.CLOSE, p.opcode);
- assertTrue(p.len >= 2);
-
- final byte[] raw = unmask(p);
- final int code = (raw[0] & 0xFF) << 8 | raw[1] & 0xFF;
- final String reason = new String(raw, 2, raw.length - 2, StandardCharsets.UTF_8);
-
- assertEquals(1000, code);
- assertEquals("done", reason);
- }
-
- @Test
- void closeEcho_masks_and_preserves_payload() {
- // Build a close payload manually
- final byte[] reason = "bye".getBytes(StandardCharsets.UTF_8);
- final ByteBuffer payload = ByteBuffer.allocate(2 + reason.length);
- payload.put((byte) (1000 >>> 8));
- payload.put((byte) (1000 & 0xFF));
- payload.put(reason);
- payload.flip();
-
- final WebSocketFrameWriter w = new WebSocketFrameWriter();
- final ByteBuffer f = w.closeEcho(payload);
- final Parsed p = parse(f);
-
- assertTrue(p.mask);
- assertEquals(FrameOpcode.CLOSE, p.opcode);
- assertEquals(2 + reason.length, p.len);
-
- final byte[] got = unmask(p);
- assertEquals(1000, (got[0] & 0xFF) << 8 | got[1] & 0xFF);
- assertEquals("bye", new String(got, 2, got.length - 2, StandardCharsets.UTF_8));
- }
-}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/message/CloseCodecTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/message/CloseCodecTest.java
deleted file mode 100644
index fe334adf38..0000000000
--- a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/core/message/CloseCodecTest.java
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * ====================================================================
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- * ====================================================================
- *
- * This software consists of voluntary contributions made by many
- * individuals on behalf of the Apache Software Foundation. For more
- * information on the Apache Software Foundation, please see
- * .
- *
- */
-package org.apache.hc.client5.http.websocket.core.message;
-
-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 java.nio.ByteBuffer;
-import java.nio.charset.StandardCharsets;
-
-import org.junit.jupiter.api.Test;
-
-final class CloseCodecTest {
-
- @Test
- void readEmptyIs1005() {
- final ByteBuffer empty = ByteBuffer.allocate(0);
- assertEquals(1005, CloseCodec.readCloseCode(empty.asReadOnlyBuffer()));
- assertEquals("", CloseCodec.readCloseReason(empty.asReadOnlyBuffer()));
- }
-
- @Test
- void readCodeAndReason() {
- final ByteBuffer payload = ByteBuffer.allocate(2 + 4);
- payload.put((byte) 0x03).put((byte) 0xE8); // 1000
- payload.put(StandardCharsets.UTF_8.encode("done"));
- payload.flip();
-
- // Use the SAME buffer so the position advances
- final ByteBuffer buf = payload.asReadOnlyBuffer();
- assertEquals(1000, CloseCodec.readCloseCode(buf)); // advances position by 2
- assertEquals("done", CloseCodec.readCloseReason(buf)); // reads remaining bytes only
- }
-
- @Test
- void validateCloseCodes() {
- assertTrue(CloseCodec.isValidToSend(1000));
- assertTrue(CloseCodec.isValidToReceive(1000));
- assertTrue(CloseCodec.isValidToSend(3000));
- assertTrue(CloseCodec.isValidToReceive(3000));
-
- assertFalse(CloseCodec.isValidToSend(1005));
- assertFalse(CloseCodec.isValidToReceive(1005));
- assertFalse(CloseCodec.isValidToSend(1006));
- assertFalse(CloseCodec.isValidToReceive(1006));
- assertFalse(CloseCodec.isValidToSend(1015));
- assertFalse(CloseCodec.isValidToReceive(1015));
-
- assertFalse(CloseCodec.isValidToSend(2000));
- assertFalse(CloseCodec.isValidToReceive(2000));
- }
-
- @Test
- void truncateReasonUtf8_capsAt123Bytes() {
- final StringBuilder sb = new StringBuilder();
- for (int i = 0; i < 130; i++) {
- sb.append('a');
- }
- final String truncated = CloseCodec.truncateReasonUtf8(sb.toString());
- assertEquals(123, truncated.getBytes(StandardCharsets.UTF_8).length);
- }
-}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java
index cb621c16aa..34a8560ca1 100644
--- a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java
@@ -53,7 +53,7 @@ public static void main(final String[] args) throws Exception {
.offerServerNoContextTakeover(true)
.offerClientNoContextTakeover(true)
.offerClientMaxWindowBits(15)
- .setCloseWaitTimeout(Timeout.ofMilliseconds(200))
+ .setCloseWaitTimeout(Timeout.ofSeconds(2))
.build();
try (final CloseableWebSocketClient client = WebSocketClientBuilder.create()
@@ -107,6 +107,7 @@ public void onError(final Throwable ex) {
done.countDown();
}
}, cfg).exceptionally(ex -> {
+ ex.printStackTrace(System.err);
done.countDown();
return null;
});
@@ -116,8 +117,6 @@ public void onError(final Throwable ex) {
System.exit(1);
}
- // Tidy shutdown: ask for shutdown, then wait briefly for the reactor to stop.
- // Try-with-resources will still call close(GRACEFUL) at the end.
client.initiateShutdown();
client.awaitShutdown(TimeValue.ofSeconds(2));
}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java
index d592ce6b87..0a5261f1e2 100644
--- a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java
@@ -26,29 +26,22 @@
*/
package org.apache.hc.client5.http.websocket.example;
+import java.io.IOException;
import java.nio.ByteBuffer;
+import java.util.concurrent.CountDownLatch;
-import org.eclipse.jetty.server.Server;
-import org.eclipse.jetty.servlet.ServletContextHandler;
-import org.eclipse.jetty.servlet.ServletHolder;
-import org.eclipse.jetty.websocket.api.Session;
-import org.eclipse.jetty.websocket.api.WebSocketAdapter;
-import org.eclipse.jetty.websocket.servlet.WebSocketServlet;
-import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
+import org.apache.hc.core5.websocket.WebSocketHandler;
+import org.apache.hc.core5.websocket.WebSocketSession;
+import org.apache.hc.core5.websocket.server.WebSocketServer;
+import org.apache.hc.core5.websocket.server.WebSocketServerBootstrap;
/**
* WebSocketEchoServer
*
- * A tiny embedded Jetty WebSocket server that echoes back any TEXT or BINARY message
- * it receives. This is intended for local development and interoperability testing of
- * {@code WebSocketClient} and is not production hardened.
- *
- * Features
- *
- * - HTTP upgrade to RFC 6455 WebSocket on path {@code /echo}
- * - Echoes TEXT and BINARY frames
- * - Compatible with permessage-deflate (RFC 7692); Jetty will negotiate it if offered
- *
+ * A tiny WebSocket echo server built on httpcore5-websocket. It echoes back
+ * any TEXT or BINARY message it receives. This is intended for local
+ * development and interoperability testing of {@code WebSocketClient} and is
+ * not production hardened.
*
* Usage
*
@@ -60,59 +53,78 @@
*
*
* Once started, the server listens on {@code ws://localhost:<port>/echo}.
- *
- * Notes
- *
- * - If the port is already in use, Jetty will fail to start with {@code BindException}.
- * - Idle timeout is set to 30 seconds for simplicity.
- *
*/
public final class WebSocketEchoServer {
+ private WebSocketEchoServer() {
+ }
+
public static void main(final String[] args) throws Exception {
final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080;
+ final CountDownLatch shutdown = new CountDownLatch(1);
- final Server server = new Server(port);
- final ServletContextHandler ctx = new ServletContextHandler(ServletContextHandler.SESSIONS);
- ctx.setContextPath("/");
- server.setHandler(ctx);
+ final WebSocketServer server = WebSocketServerBootstrap.bootstrap()
+ .setListenerPort(port)
+ .setCanonicalHostName("localhost")
+ .register("/echo", () -> new WebSocketHandler() {
+ @Override
+ public void onOpen(final WebSocketSession session) {
+ System.out.println("WebSocket open: " + session.getRemoteAddress());
+ }
- ctx.addServlet(new ServletHolder(new EchoServlet()), "/echo");
- server.start();
- System.out.println("[WS-Server] up at ws://localhost:" + port + "/echo");
- server.join();
- }
+ @Override
+ public void onText(final WebSocketSession session, final String text) {
+ try {
+ session.sendText(text);
+ } catch (final IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
- /**
- * Simple servlet that wires a Jetty WebSocket endpoint at {@code /echo}.
- */
- public static final class EchoServlet extends WebSocketServlet {
- @Override
- public void configure(final WebSocketServletFactory factory) {
- factory.getPolicy().setIdleTimeout(30_000);
- // Jetty will negotiate permessage-deflate automatically if supported.
- factory.setCreator((req, resp) -> new EchoSocket());
- }
- }
+ @Override
+ public void onBinary(final WebSocketSession session, final ByteBuffer data) {
+ try {
+ session.sendBinary(data);
+ } catch (final IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ @Override
+ public void onPing(final WebSocketSession session, final ByteBuffer data) {
+ try {
+ session.sendPong(data);
+ } catch (final IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ @Override
+ public void onClose(final WebSocketSession session, final int statusCode, final String reason) {
+ System.out.println("WebSocket close: " + statusCode + " " + reason);
+ }
+
+ @Override
+ public void onError(final WebSocketSession session, final Exception cause) {
+ System.err.println("WebSocket error: " + cause.getMessage());
+ cause.printStackTrace(System.err);
+ }
- /**
- * Echoes back text and binary messages.
- */
- public static final class EchoSocket extends WebSocketAdapter {
- @Override
- public void onWebSocketText(final String msg) {
- final Session s = getSession();
- if (s != null && s.isOpen()) {
- s.getRemote().sendString(msg, null);
- }
- }
+ @Override
+ public String selectSubprotocol(final java.util.List protocols) {
+ return protocols.isEmpty() ? null : protocols.get(0);
+ }
+ })
+ .create();
- @Override
- public void onWebSocketBinary(final byte[] payload, final int off, final int len) {
- final Session s = getSession();
- if (s != null && s.isOpen()) {
- s.getRemote().sendBytes(ByteBuffer.wrap(payload, off, len), null);
- }
- }
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ server.initiateShutdown();
+ server.stop();
+ shutdown.countDown();
+ }));
+
+ server.start();
+ System.out.println("[WS-Server] up at ws://localhost:" + port + "/echo");
+ shutdown.await();
}
}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java
index c5bb68da02..e16e0c7f2b 100644
--- a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java
@@ -33,8 +33,8 @@
import java.nio.ByteBuffer;
-import org.apache.hc.client5.http.websocket.core.exceptions.WebSocketProtocolException;
-import org.apache.hc.client5.http.websocket.core.frame.FrameOpcode;
+import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException;
+import org.apache.hc.core5.websocket.frame.FrameOpcode;
import org.junit.jupiter.api.Test;
class WsDecoderTest {
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundCompressionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundCompressionTest.java
index cbd695efe8..41cf9a4e66 100644
--- a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundCompressionTest.java
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundCompressionTest.java
@@ -40,9 +40,9 @@
import org.apache.hc.client5.http.websocket.api.WebSocket;
import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
import org.apache.hc.client5.http.websocket.api.WebSocketListener;
-import org.apache.hc.client5.http.websocket.core.extension.ExtensionChain;
-import org.apache.hc.client5.http.websocket.core.extension.PerMessageDeflate;
-import org.apache.hc.client5.http.websocket.core.frame.FrameOpcode;
+import org.apache.hc.core5.websocket.extension.ExtensionChain;
+import org.apache.hc.core5.websocket.extension.PerMessageDeflate;
+import org.apache.hc.core5.websocket.frame.FrameOpcode;
import org.apache.hc.core5.reactor.ProtocolIOSession;
import org.junit.jupiter.api.Test;
diff --git a/pom.xml b/pom.xml
index 0ceab71195..d3c57a0baa 100644
--- a/pom.xml
+++ b/pom.xml
@@ -62,7 +62,7 @@
1.8
1.8
- 5.4
+ 5.5-alpha1-SNAPSHOT
2.25.3
1.20.0
2.5.2
@@ -100,6 +100,11 @@
httpcore5-h2
${httpcore.version}
+
+ org.apache.httpcomponents.core5
+ httpcore5-websocket
+ ${httpcore.version}
+
org.apache.httpcomponents.core5
httpcore5-testing