scenarios = new ArrayList<>();
+
+ scenarios.add(new String[] {
+ "protocol=h1",
+ "mode=THROUGHPUT",
+ "clients=8",
+ "durationSec=10",
+ "bytes=512",
+ "inflight=32",
+ "pmce=false",
+ "compressible=true"
+ });
+ scenarios.add(new String[] {
+ "protocol=h1",
+ "mode=THROUGHPUT",
+ "clients=8",
+ "durationSec=10",
+ "bytes=512",
+ "inflight=32",
+ "pmce=false",
+ "compressible=false"
+ });
+ scenarios.add(new String[] {
+ "protocol=h2",
+ "mode=THROUGHPUT",
+ "clients=8",
+ "durationSec=10",
+ "bytes=512",
+ "inflight=32",
+ "pmce=false",
+ "compressible=false"
+ });
+ scenarios.add(new String[] {
+ "protocol=h2",
+ "mode=THROUGHPUT",
+ "clients=8",
+ "durationSec=10",
+ "bytes=512",
+ "inflight=32",
+ "pmce=true",
+ "compressible=false"
+ });
+ scenarios.add(new String[] {
+ "protocol=h2",
+ "mode=LATENCY",
+ "clients=4",
+ "durationSec=10",
+ "bytes=64",
+ "inflight=4",
+ "pmce=false",
+ "compressible=false"
+ });
+
+ final int total = scenarios.size();
+ for (int i = 0; i < total; i++) {
+ final String[] scenario = scenarios.get(i);
+ System.out.println("\n[PERF] Scenario " + (i + 1) + "/" + total + ": " + String.join(" ", scenario));
+ WsPerfHarness.main(scenario);
+ }
+ }
+}
diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfEchoServer.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfEchoServer.java
new file mode 100644
index 0000000000..b5d143fc51
--- /dev/null
+++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfEchoServer.java
@@ -0,0 +1,102 @@
+/*
+ * ====================================================================
+ * 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 java.util.concurrent.CountDownLatch;
+
+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;
+
+public final class WsPerfEchoServer {
+ private WebSocketServer server;
+ private int port;
+
+ public void start() throws Exception {
+ start(0);
+ }
+
+ public void start(final int listenerPort) throws Exception {
+ server = WebSocketServerBootstrap.bootstrap()
+ .setListenerPort(listenerPort)
+ .setCanonicalHostName("127.0.0.1")
+ .register("/echo", EchoHandler::new)
+ .create();
+ server.start();
+ this.port = server.getLocalPort();
+ }
+
+ public void stop() throws Exception {
+ if (server != null) {
+ server.stop();
+ }
+ }
+
+ public String uri() {
+ return "ws://127.0.0.1:" + port + "/echo";
+ }
+
+ public static void main(final String[] args) throws Exception {
+ final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080;
+ final WsPerfEchoServer server = new WsPerfEchoServer();
+ server.start(port);
+ System.out.println("[PERF] echo server started at " + server.uri());
+ final CountDownLatch done = new CountDownLatch(1);
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ try {
+ server.stop();
+ } catch (final Exception ignore) {
+ } finally {
+ done.countDown();
+ }
+ }));
+ done.await();
+ }
+
+ private static final class EchoHandler implements WebSocketHandler {
+ @Override
+ public void onText(final WebSocketSession session, final String text) {
+ try {
+ session.sendText(text);
+ } catch (final IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ @Override
+ public void onBinary(final WebSocketSession session, final ByteBuffer data) {
+ try {
+ session.sendBinary(data != null ? data.asReadOnlyBuffer() : ByteBuffer.allocate(0));
+ } catch (final IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+ }
+}
diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfHarness.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfHarness.java
new file mode 100644
index 0000000000..cc063e16ef
--- /dev/null
+++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/websocket/performance/WsPerfHarness.java
@@ -0,0 +1,424 @@
+/*
+ * ====================================================================
+ * 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;
+import org.apache.hc.core5.http.ConnectionClosedException;
+import org.apache.hc.core5.reactor.IOReactorStatus;
+import org.apache.hc.core5.http.protocol.HttpCoreContext;
+import org.apache.hc.core5.websocket.WebSocketHandler;
+import org.apache.hc.core5.websocket.WebSocketSession;
+import org.apache.hc.core5.websocket.server.WebSocketH2Server;
+import org.apache.hc.core5.websocket.server.WebSocketH2ServerBootstrap;
+import org.apache.hc.core5.websocket.server.WebSocketServer;
+import org.apache.hc.core5.websocket.server.WebSocketServerBootstrap;
+
+/**
+ * Simple H1/H2 WebSocket performance harness that starts a local echo server
+ * and drives multiple clients against it.
+ *
+ * Example:
+ * protocol=h1 mode=THROUGHPUT clients=8 durationSec=10 bytes=512 inflight=32 pmce=false compressible=true
+ * protocol=h2 mode=LATENCY clients=4 durationSec=10 bytes=64 inflight=4 pmce=false compressible=false
+ */
+public final class WsPerfHarness {
+
+ private WsPerfHarness() {
+ }
+
+ public static void main(final String[] args) throws Exception {
+ final Args a = Args.parse(args);
+ final HarnessServer server = a.uri != null ? null : startServer(a);
+ final String uri = a.uri != null ? a.uri : server.uri();
+
+ System.out.printf(Locale.ROOT,
+ "protocol=%s mode=%s uri=%s clients=%d durationSec=%d bytes=%d inflight=%d pmce=%s compressible=%s%n",
+ a.protocol, a.mode, 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 go = new CountDownLatch(1);
+ final CountDownLatch done = new CountDownLatch(a.clients);
+
+ final byte[] payload = a.compressible ? makeCompressible(a.bytes) : makeRandom(a.bytes);
+ final AtomicLong deadlineRef = new AtomicLong();
+
+ for (int i = 0; i < a.clients; i++) {
+ final int id = i;
+ pool.submit(() -> runClient(id, a, uri, payload, sends, recvs, errors, lats, ready, go, done, deadlineRef));
+ }
+
+ final long awaitMs = TimeUnit.SECONDS.toMillis(a.durationSec + 15L);
+ if (!ready.await(awaitMs, TimeUnit.MILLISECONDS)) {
+ System.out.println("[PERF] timeout waiting for clients to connect");
+ }
+ deadlineRef.set(System.nanoTime() + TimeUnit.SECONDS.toNanos(a.durationSec));
+ go.countDown();
+ if (!done.await(awaitMs, TimeUnit.MILLISECONDS)) {
+ System.out.println("[PERF] timeout waiting for clients to finish");
+ }
+ 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);
+ }
+
+ if (server != null) {
+ server.stop();
+ }
+ }
+
+ private static HarnessServer startServer(final Args a) throws Exception {
+ if (a.protocol == Protocol.H2) {
+ final WebSocketH2Server server = WebSocketH2ServerBootstrap.bootstrap()
+ .setListenerPort(a.port)
+ .setCanonicalHostName(a.host)
+ .register("/echo", EchoHandler::new)
+ .create();
+ server.start();
+ return new HarnessServer(server.getLocalPort(), server);
+ }
+ final WebSocketServer server = WebSocketServerBootstrap.bootstrap()
+ .setListenerPort(a.port)
+ .setCanonicalHostName(a.host)
+ .register("/echo", EchoHandler::new)
+ .create();
+ server.start();
+ return new HarnessServer(server.getLocalPort(), server);
+ }
+
+ private static void runClient(
+ final int id, final Args a, final String uri, final byte[] payload,
+ final AtomicLong sends, final AtomicLong recvs, final AtomicLong errors,
+ final ConcurrentLinkedQueue lats,
+ final CountDownLatch ready, final CountDownLatch go,
+ final CountDownLatch done, final AtomicLong deadlineRef) {
+
+ final WebSocketClientConfig.Builder b = WebSocketClientConfig.custom()
+ .setConnectTimeout(org.apache.hc.core5.util.Timeout.ofSeconds(5))
+ .setCloseWaitTimeout(org.apache.hc.core5.util.Timeout.ofSeconds(3))
+ .setOutgoingChunkSize(4096)
+ .setAutoPong(true)
+ .enableHttp2(a.protocol == Protocol.H2);
+
+ if (a.pmce) {
+ b.enablePerMessageDeflate(true)
+ .offerClientNoContextTakeover(false)
+ .offerServerNoContextTakeover(false)
+ .offerClientMaxWindowBits(null)
+ .offerServerMaxWindowBits(null);
+ }
+ final WebSocketClientConfig cfg = b.build();
+
+ try (final CloseableWebSocketClient client =
+ WebSocketClientBuilder.create()
+ .defaultConfig(cfg)
+ .build()) {
+ client.start();
+ waitForStart(client, id);
+
+ final AtomicInteger inflight = new AtomicInteger();
+ final AtomicBoolean open = new AtomicBoolean(false);
+ final AtomicBoolean readyCounted = new AtomicBoolean(false);
+
+ System.out.printf(Locale.ROOT, "[PERF] client-%d connecting to %s%n", id, uri);
+ final CompletableFuture cf = client.connect(
+ URI.create(uri),
+ new WebSocketListener() {
+ @Override
+ public void onOpen(final WebSocket ws) {
+ open.set(true);
+ if (readyCounted.compareAndSet(false, true)) {
+ ready.countDown();
+ }
+ 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();
+ open.set(false);
+ if (!(ex instanceof ConnectionClosedException)) {
+ System.out.printf(Locale.ROOT, "[PERF] client-%d error: %s%n", id, ex.toString());
+ }
+ }
+
+ @Override
+ public void onClose(final int code, final String reason) {
+ open.set(false);
+ }
+ }, cfg, HttpCoreContext.create());
+ cf.whenComplete((ws, ex) -> {
+ if (ex != null) {
+ errors.incrementAndGet();
+ if (!(ex instanceof ConnectionClosedException)) {
+ System.out.printf(Locale.ROOT, "[PERF] client-%d connect failed: %s%n", id, ex.toString());
+ }
+ }
+ });
+
+ try {
+ final WebSocket ws = cf.get(15, TimeUnit.SECONDS);
+ if (!go.await(10, TimeUnit.SECONDS)) {
+ System.out.printf(Locale.ROOT, "[PERF] client-%d start timeout%n", id);
+ }
+ final long deadlineNanos = deadlineRef.get();
+
+ while (System.nanoTime() < deadlineNanos) {
+ while (open.get() && inflight.get() < a.inflight) {
+ sendOne(ws, a, payload, sends, inflight);
+ }
+ LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
+ }
+
+ Thread.sleep(200);
+ ws.close(1000, "bye");
+ } catch (final Exception e) {
+ errors.incrementAndGet();
+ System.out.printf(Locale.ROOT, "[PERF] client-%d connect timeout/failure: %s%n", id, e);
+ if (readyCounted.compareAndSet(false, true)) {
+ ready.countDown();
+ }
+ } finally {
+ if (readyCounted.compareAndSet(false, true)) {
+ ready.countDown();
+ }
+ done.countDown();
+ }
+ } catch (final Exception ignore) {
+ }
+ }
+
+ private static void waitForStart(final CloseableWebSocketClient client, final int id) {
+ final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (System.nanoTime() < deadline) {
+ if (client.getStatus() != null && client.getStatus() == IOReactorStatus.ACTIVE) {
+ return;
+ }
+ LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(20));
+ }
+ System.out.printf(Locale.ROOT, "[PERF] client-%d start timeout (status=%s)%n", id, client.getStatus());
+ }
+
+ 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 enum Protocol { H1, H2 }
+
+ private static final class Args {
+ Protocol protocol = Protocol.H1;
+ String uri;
+ String host = "127.0.0.1";
+ int port = 0;
+ 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 "protocol":
+ r.protocol = Protocol.valueOf(kv[1].toUpperCase(Locale.ROOT));
+ break;
+ case "uri":
+ r.uri = kv[1];
+ break;
+ case "host":
+ r.host = kv[1];
+ break;
+ case "port":
+ r.port = Integer.parseInt(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].toUpperCase(Locale.ROOT));
+ break;
+ }
+ }
+ return r;
+ }
+ }
+
+ private static final class HarnessServer {
+ private final int port;
+ private final WebSocketServer h1;
+ private final WebSocketH2Server h2;
+
+ HarnessServer(final int port, final WebSocketServer h1) {
+ this.port = port;
+ this.h1 = h1;
+ this.h2 = null;
+ }
+
+ HarnessServer(final int port, final WebSocketH2Server h2) {
+ this.port = port;
+ this.h1 = null;
+ this.h2 = h2;
+ }
+
+ String uri() {
+ return "ws://127.0.0.1:" + port + "/echo";
+ }
+
+ void stop() throws Exception {
+ if (h2 != null) {
+ h2.stop();
+ } else if (h1 != null) {
+ h1.stop();
+ }
+ }
+ }
+
+ private static final class EchoHandler implements WebSocketHandler {
+ @Override
+ public void onBinary(final WebSocketSession session, final ByteBuffer data) {
+ try {
+ session.sendBinary(data != null ? data.asReadOnlyBuffer() : ByteBuffer.allocate(0));
+ } catch (final Exception ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ @Override
+ public void onText(final WebSocketSession session, final String text) {
+ try {
+ session.sendText(text);
+ } catch (final Exception ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+ }
+
+ 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-websocket/pom.xml b/httpclient5-websocket/pom.xml
new file mode 100644
index 0000000000..16aa7b0026
--- /dev/null
+++ b/httpclient5-websocket/pom.xml
@@ -0,0 +1,112 @@
+
+
+ 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.apache.httpcomponents.core5
+ httpcore5-websocket
+
+
+ org.apache.httpcomponents.core5
+ httpcore5-h2
+
+
+ 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.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..10a72aa4ec
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocket.java
@@ -0,0 +1,144 @@
+/*
+ * ====================================================================
+ * 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}.
+ *
+ * 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 {
+
+ /**
+ * 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..cabb90ff72
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java
@@ -0,0 +1,607 @@
+/*
+ * ====================================================================
+ * 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.
+ *
+ * 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 {
+
+ 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;
+ private final boolean http2Enabled;
+
+ // 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,
+ final boolean http2Enabled) {
+
+ 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;
+ this.http2Enabled = http2Enabled;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Returns {@code true} if HTTP/2 Extended CONNECT (RFC 8441) is enabled.
+ *
+ * @since 5.7
+ */
+ public boolean isHttp2Enabled() {
+ return http2Enabled;
+ }
+
+ /**
+ * 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;
+ private boolean http2Enabled;
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Enables HTTP/2 Extended CONNECT (RFC 8441) for supported endpoints.
+ *
+ * @param enabled true to enable HTTP/2 WebSocket connections
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder enableHttp2(final boolean enabled) {
+ this.http2Enabled = enabled;
+ 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,
+ http2Enabled
+ );
+ }
+ }
+}
\ 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..c2ad5ca393
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketListener.java
@@ -0,0 +1,103 @@
+/*
+ * ====================================================================
+ * 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.
+ *
+ * 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 {
+
+ /**
+ * 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..622f951238
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/CloseableWebSocketClient.java
@@ -0,0 +1,123 @@
+/*
+ * ====================================================================
+ * 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. A graceful close allows in-flight I/O to finish,
+ * while immediate close aborts active operations.
+ *
+ * @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..cbe660179a
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClient.java
@@ -0,0 +1,77 @@
+/*
+ * ====================================================================
+ * 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.
+ *
+ * This interface represents the minimal contract for initiating
+ * WebSocket handshakes. Implementations are expected to be thread-safe.
+ *
+ * @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..8927bb32bb
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java
@@ -0,0 +1,467 @@
+/*
+ * ====================================================================
+ * 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.http2.config.H2Config;
+import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester;
+import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequesterBootstrap;
+import org.apache.hc.core5.http2.impl.H2Processors;
+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.
+ *
+ * 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 {
+
+ 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 H2Config h2Config;
+ 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,
+ 0
+ );
+
+ final H2MultiplexingRequester h2Requester = H2MultiplexingRequesterBootstrap.bootstrap()
+ .setIOReactorConfig(ioReactorConfig != null ? ioReactorConfig : IOReactorConfig.DEFAULT)
+ .setHttpProcessor(httpProcessor != null ? httpProcessor : H2Processors.client())
+ .setH2Config(h2Config != null ? h2Config : H2Config.DEFAULT)
+ .setTlsStrategy(tls)
+ .setIOSessionDecorator(ioSessionDecorator)
+ .setExceptionCallback(exceptionCallback != null ? exceptionCallback : WsLoggingExceptionCallback.INSTANCE)
+ .setIOSessionListener(sessionListener)
+ .setIOReactorMetricsListener(metricsListener)
+ .create();
+
+ final ThreadFactory tf = threadFactory != null
+ ? threadFactory
+ : new DefaultThreadFactory("websocket-main", true);
+
+ return new DefaultWebSocketClient(
+ requester,
+ connPool,
+ defaultConfig,
+ tf,
+ h2Requester
+ );
+ }
+}
\ 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..e7bcb91b7b
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClients.java
@@ -0,0 +1,81 @@
+/*
+ * ====================================================================
+ * 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.
+ *
+ * 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 {
+
+ 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..ecab82b8b4
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/AbstractWebSocketClient.java
@@ -0,0 +1,125 @@
+/*
+ * ====================================================================
+ * 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.AsyncRequester;
+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 AsyncRequester primaryRequester;
+ private final AsyncRequester[] extraRequesters;
+ private final ExecutorService executorService;
+ private final AtomicReference status;
+
+ AbstractWebSocketClient(final HttpAsyncRequester requester, final ThreadFactory threadFactory, final AsyncRequester... extraRequesters) {
+ super();
+ this.primaryRequester = Args.notNull(requester, "requester");
+ this.extraRequesters = extraRequesters != null ? extraRequesters : new AsyncRequester[0];
+ 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(() -> {
+ primaryRequester.start();
+ for (final AsyncRequester requester : extraRequesters) {
+ requester.start();
+ }
+ });
+ }
+ }
+
+ boolean isRunning() {
+ return status.get() == Status.RUNNING;
+ }
+
+ @Override
+ public final IOReactorStatus getStatus() {
+ return primaryRequester.getStatus();
+ }
+
+ @Override
+ public final void awaitShutdown(final TimeValue waitTime) throws InterruptedException {
+ primaryRequester.awaitShutdown(waitTime);
+ for (final AsyncRequester requester : extraRequesters) {
+ requester.awaitShutdown(waitTime);
+ }
+ }
+
+ @Override
+ public final void initiateShutdown() {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Initiating shutdown");
+ }
+ primaryRequester.initiateShutdown();
+ for (final AsyncRequester requester : extraRequesters) {
+ requester.initiateShutdown();
+ }
+ }
+
+ void internalClose(final CloseMode closeMode) {
+ }
+
+ @Override
+ public final void close(final CloseMode closeMode) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Shutdown {}", closeMode);
+ }
+ primaryRequester.initiateShutdown();
+ primaryRequester.close(closeMode != null ? closeMode : CloseMode.IMMEDIATE);
+ for (final AsyncRequester requester : extraRequesters) {
+ 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..b1d94ba1d4
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/DefaultWebSocketClient.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;
+
+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.http2.impl.nio.bootstrap.H2MultiplexingRequester;
+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,
+ final H2MultiplexingRequester h2Requester) {
+ super(requester, connPool, defaultConfig, threadFactory, h2Requester);
+ }
+}
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..cf0e2bf504
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/InternalWebSocketClientBase.java
@@ -0,0 +1,119 @@
+/*
+ * ====================================================================
+ * 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.Http2ExtendedConnectProtocol;
+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.http2.impl.nio.bootstrap.H2MultiplexingRequester;
+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;
+ private final WebSocketProtocolStrategy h2;
+
+ InternalWebSocketClientBase(
+ final HttpAsyncRequester requester,
+ final ManagedConnPool connPool,
+ final WebSocketClientConfig defaultConfig,
+ final ThreadFactory threadFactory,
+ final H2MultiplexingRequester h2Requester) {
+ super(Args.notNull(requester, "requester"), threadFactory, h2Requester);
+ this.connPool = Args.notNull(connPool, "connPool");
+ this.defaultConfig = defaultConfig != null ? defaultConfig : WebSocketClientConfig.custom().build();
+ this.h1 = newH1Protocol(requester, connPool);
+ this.h2 = newH2Protocol(h2Requester);
+ }
+
+ /**
+ * HTTP/1.1 Upgrade protocol.
+ */
+ protected WebSocketProtocolStrategy newH1Protocol(
+ final HttpAsyncRequester requester,
+ final ManagedConnPool connPool) {
+ return new Http1UpgradeProtocol(requester, connPool);
+ }
+
+ /**
+ * HTTP/2 Extended CONNECT protocol.
+ */
+ protected WebSocketProtocolStrategy newH2Protocol(final H2MultiplexingRequester requester) {
+ return requester != null ? new Http2ExtendedConnectProtocol(requester) : null;
+ }
+
+ @Override
+ protected CompletableFuture doConnect(
+ final URI uri,
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfgOrNull,
+ final HttpContext context) {
+
+ final WebSocketClientConfig cfg = cfgOrNull != null ? cfgOrNull : defaultConfig;
+ if (cfg.isHttp2Enabled() && h2 != null) {
+ return h2.connect(uri, listener, cfg, context);
+ }
+ 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..325440139c
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/logging/WsLoggingExceptionCallback.java
@@ -0,0 +1,58 @@
+/*
+ * ====================================================================
+ * 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.apache.hc.core5.http.ConnectionClosedException;
+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) {
+ if (ex instanceof ConnectionClosedException) {
+ LOG.debug(ex.getMessage(), ex);
+ return;
+ }
+ 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..a1a52ddfed
--- /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.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;
+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..d78f4a5672
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocol.java
@@ -0,0 +1,624 @@
+/*
+ * ====================================================================
+ * 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.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayDeque;
+import java.util.Base64;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ThreadLocalRandom;
+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.transport.WebSocketFrameDecoder;
+import org.apache.hc.core5.annotation.Internal;
+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.HttpHost;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.Method;
+import org.apache.hc.core5.http.ProtocolException;
+import org.apache.hc.core5.http.URIScheme;
+import org.apache.hc.core5.http.impl.BasicEntityDetails;
+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.http2.H2PseudoRequestHeaders;
+import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.Timeout;
+import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException;
+import org.apache.hc.core5.websocket.extension.ExtensionChain;
+import org.apache.hc.core5.websocket.extension.WebSocketExtensionChain;
+import org.apache.hc.core5.websocket.frame.FrameHeaderBits;
+import org.apache.hc.core5.websocket.frame.FrameOpcode;
+import org.apache.hc.core5.websocket.frame.WebSocketFrameWriter;
+import org.apache.hc.core5.websocket.message.CloseCodec;
+
+/**
+ * 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);
+ }
+ }
+
+ private final H2MultiplexingRequester requester;
+
+ public Http2ExtendedConnectProtocol(final H2MultiplexingRequester requester) {
+ this.requester = requester;
+ }
+
+ @Override
+ public CompletableFuture connect(
+ final URI uri,
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfg,
+ final HttpContext context) {
+ final CompletableFuture f = new CompletableFuture<>();
+ if (requester == null) {
+ f.completeExceptionally(new H2NotAvailable("HTTP/2 requester not configured"));
+ return f;
+ }
+ 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())) {
+ 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 String secKey = randomKey();
+ final BasicHttpRequest req = new BasicHttpRequest(Method.CONNECT.name(), target, fullPath);
+ req.addHeader(H2PseudoRequestHeaders.PROTOCOL, "websocket");
+ req.addHeader("Sec-WebSocket-Version", "13");
+ req.addHeader("Sec-WebSocket-Key", secKey);
+
+ if (cfg.getSubprotocols() != null && !cfg.getSubprotocols().isEmpty()) {
+ final StringBuilder sb = new StringBuilder();
+ for (final String p : cfg.getSubprotocols()) {
+ if (p != null && !p.isEmpty()) {
+ if (sb.length() > 0) {
+ sb.append(", ");
+ }
+ sb.append(p);
+ }
+ }
+ if (sb.length() > 0) {
+ req.addHeader("Sec-WebSocket-Protocol", sb.toString());
+ }
+ }
+
+ 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());
+ }
+
+ final Timeout timeout = cfg.getConnectTimeout() != null ? cfg.getConnectTimeout() : Timeout.ofSeconds(10);
+ requester.execute(target, new H2WebSocketExchangeHandler(req, secKey, listener, cfg, f), null, timeout, context);
+ return f;
+ }
+
+ private static String randomKey() {
+ final byte[] nonce = new byte[16];
+ ThreadLocalRandom.current().nextBytes(nonce);
+ return Base64.getEncoder().encodeToString(nonce);
+ }
+
+ private static final class H2WebSocketExchangeHandler implements AsyncClientExchangeHandler {
+
+ private final BasicHttpRequest request;
+ private final String key;
+ private final WebSocketListener listener;
+ private final WebSocketClientConfig cfg;
+ private final CompletableFuture future;
+ private final H2WebSocket webSocket;
+ private final WebSocketFrameWriter writer;
+ private WebSocketFrameDecoder decoder;
+
+ private ByteBuffer inbuf = ByteBuffer.allocate(8192);
+ private final AtomicBoolean open = new AtomicBoolean(true);
+ private final AtomicBoolean outputPrimed = new AtomicBoolean(false);
+ private ExtensionChain.EncodeChain encChain;
+ private ExtensionChain.DecodeChain decChain;
+ private int assemblingOpcode = -1;
+ private boolean assemblingCompressed;
+ private ByteArrayOutputStream assemblingBytes;
+
+ private volatile DataStreamChannel dataChannel;
+
+ H2WebSocketExchangeHandler(
+ final BasicHttpRequest request,
+ final String key,
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfg,
+ final CompletableFuture future) {
+ this.request = request;
+ this.key = key;
+ this.listener = listener;
+ this.cfg = cfg;
+ this.future = future;
+ this.writer = new WebSocketFrameWriter();
+ this.webSocket = new H2WebSocket();
+ }
+
+ @Override
+ public void produceRequest(final RequestChannel channel, final HttpContext context) throws HttpException, IOException {
+ channel.sendRequest(request, new BasicEntityDetails(-1, null), context);
+ }
+
+ @Override
+ public void consumeResponse(final HttpResponse response, final EntityDetails entityDetails, final HttpContext context) throws HttpException, IOException {
+ if (response.getCode() != HttpStatus.SC_OK) {
+ future.completeExceptionally(new IllegalStateException("Unexpected status: " + response.getCode()));
+ return;
+ }
+ if (containsHeader(response, "Sec-WebSocket-Accept")) {
+ final String accept = response.getFirstHeader("Sec-WebSocket-Accept").getValue();
+ try {
+ final String expected = expectedAccept(key);
+ if (!expected.equals(accept)) {
+ throw new ProtocolException("Invalid Sec-WebSocket-Accept");
+ }
+ } catch (final Exception ex) {
+ future.completeExceptionally(ex);
+ return;
+ }
+ }
+
+ final ExtensionChain chain = Http1UpgradeProtocol.buildExtensionChain(cfg, headerValue(response, "Sec-WebSocket-Extensions"));
+ this.encChain = chain.isEmpty() ? null : chain.newEncodeChain();
+ this.decChain = chain.isEmpty() ? null : chain.newDecodeChain();
+ this.decoder = new WebSocketFrameDecoder(
+ cfg.getMaxFrameSize(), chain.isEmpty(), false);
+
+ future.complete(webSocket);
+ listener.onOpen(webSocket);
+ }
+
+ @Override
+ public void consumeInformation(final HttpResponse response, final HttpContext context) throws HttpException, IOException {
+ }
+
+ @Override
+ public void updateCapacity(final CapacityChannel capacityChannel) throws IOException {
+ capacityChannel.update(Integer.MAX_VALUE);
+ }
+
+ @Override
+ public void consume(final ByteBuffer src) throws IOException {
+ if (!open.get()) {
+ return;
+ }
+ if (decoder == null) {
+ return;
+ }
+ appendToInbuf(src);
+ inbuf.flip();
+ for (; ; ) {
+ final boolean has;
+ try {
+ has = decoder.decode(inbuf);
+ } catch (final RuntimeException ex) {
+ listener.onError(ex);
+ open.set(false);
+ return;
+ }
+ if (!has) {
+ break;
+ }
+ handleFrame();
+ }
+ inbuf.compact();
+ }
+
+ @Override
+ public void streamEnd(final List extends Header> trailers) throws HttpException, IOException {
+ open.set(false);
+ }
+
+ @Override
+ public int available() {
+ if (dataChannel == null && outputPrimed.compareAndSet(false, true)) {
+ // Force a first produce() call to capture the output channel.
+ return 1;
+ }
+ return webSocket.available();
+ }
+
+ @Override
+ public void produce(final DataStreamChannel channel) throws IOException {
+ this.dataChannel = channel;
+ webSocket.produce(channel);
+ }
+
+ @Override
+ public void failed(final Exception cause) {
+ listener.onError(cause);
+ open.set(false);
+ }
+
+ @Override
+ public void releaseResources() {
+ open.set(false);
+ }
+
+ @Override
+ public void cancel() {
+ open.set(false);
+ }
+
+ private void handleFrame() {
+ final int op = decoder.opcode();
+ final boolean fin = decoder.fin();
+ final boolean r1 = decoder.rsv1();
+ final boolean r2 = decoder.rsv2();
+ final boolean r3 = decoder.rsv3();
+ final ByteBuffer payload = decoder.payload();
+
+ if (r2 || r3) {
+ listener.onError(new WebSocketProtocolException(1002, "RSV2/RSV3 not supported"));
+ open.set(false);
+ return;
+ }
+ if (r1 && decChain == null) {
+ listener.onError(new WebSocketProtocolException(1002, "RSV1 without negotiated extension"));
+ open.set(false);
+ return;
+ }
+ if (FrameOpcode.isControl(op)) {
+ if (!fin) {
+ listener.onError(new WebSocketProtocolException(1002, "fragmented control frame"));
+ open.set(false);
+ return;
+ }
+ if (payload.remaining() > 125) {
+ listener.onError(new WebSocketProtocolException(1002, "control frame too large"));
+ open.set(false);
+ return;
+ }
+ }
+ switch (op) {
+ case FrameOpcode.PING:
+ listener.onPing(payload.asReadOnlyBuffer());
+ if (cfg.isAutoPong()) {
+ webSocket.pong(payload.asReadOnlyBuffer());
+ }
+ break;
+ case FrameOpcode.PONG:
+ listener.onPong(payload.asReadOnlyBuffer());
+ break;
+ case FrameOpcode.CLOSE:
+ int code = 1005;
+ String reason = "";
+ if (payload.remaining() == 1) {
+ listener.onError(new WebSocketProtocolException(1002, "Invalid close payload length"));
+ open.set(false);
+ return;
+ } else if (payload.remaining() >= 2) {
+ final ByteBuffer dup = payload.slice();
+ code = CloseCodec.readCloseCode(dup);
+ if (!CloseCodec.isValidToReceive(code)) {
+ listener.onError(new WebSocketProtocolException(1002, "Invalid close code"));
+ open.set(false);
+ return;
+ }
+ if (dup.hasRemaining()) {
+ reason = StandardCharsets.UTF_8.decode(dup.asReadOnlyBuffer()).toString();
+ }
+ }
+ listener.onClose(code, reason);
+ open.set(false);
+ break;
+ case FrameOpcode.TEXT:
+ case FrameOpcode.BINARY:
+ if (assemblingOpcode != -1) {
+ listener.onError(new WebSocketProtocolException(1002, "New data frame while fragmented message in progress"));
+ open.set(false);
+ return;
+ }
+ if (!fin) {
+ assemblingOpcode = op;
+ assemblingCompressed = r1 && decChain != null;
+ assemblingBytes = new ByteArrayOutputStream(Math.max(1024, payload.remaining()));
+ appendPayload(payload);
+ return;
+ }
+ deliverSingle(op, payload, r1);
+ break;
+ case FrameOpcode.CONT:
+ if (assemblingOpcode == -1) {
+ listener.onError(new WebSocketProtocolException(1002, "Unexpected continuation frame"));
+ open.set(false);
+ return;
+ }
+ appendPayload(payload);
+ if (fin) {
+ final ByteBuffer full = ByteBuffer.wrap(assemblingBytes.toByteArray());
+ final int opcode = assemblingOpcode;
+ final boolean compressed = assemblingCompressed;
+ assemblingOpcode = -1;
+ assemblingCompressed = false;
+ assemblingBytes = null;
+ deliverSingle(opcode, full, compressed);
+ }
+ break;
+ default:
+ listener.onError(new WebSocketProtocolException(1002, "Unsupported opcode: " + op));
+ open.set(false);
+ }
+ }
+
+ private void deliverSingle(final int opcode, final ByteBuffer payload, final boolean rsv1) {
+ ByteBuffer data = payload.asReadOnlyBuffer();
+ if (rsv1 && decChain != null) {
+ try {
+ data = ByteBuffer.wrap(decChain.decode(toBytes(data)));
+ } catch (final Exception ex) {
+ listener.onError(ex);
+ return;
+ }
+ }
+ if (opcode == FrameOpcode.TEXT) {
+ listener.onText(StandardCharsets.UTF_8.decode(data), true);
+ } else {
+ listener.onBinary(data, true);
+ }
+ }
+
+ private void appendPayload(final ByteBuffer payload) {
+ if (assemblingBytes == null) {
+ assemblingBytes = new ByteArrayOutputStream();
+ }
+ final byte[] tmp = toBytes(payload);
+ assemblingBytes.write(tmp, 0, tmp.length);
+ }
+
+ private void appendToInbuf(final ByteBuffer src) {
+ if (src == null || !src.hasRemaining()) {
+ return;
+ }
+ if (inbuf.remaining() < src.remaining()) {
+ final int need = inbuf.position() + src.remaining();
+ final int newCap = Math.max(inbuf.capacity() * 2, need);
+ final ByteBuffer bigger = ByteBuffer.allocate(newCap);
+ inbuf.flip();
+ bigger.put(inbuf);
+ inbuf = bigger;
+ }
+ inbuf.put(src);
+ }
+
+ private byte[] toBytes(final ByteBuffer buf) {
+ final ByteBuffer b = buf.asReadOnlyBuffer();
+ final byte[] out = new byte[b.remaining()];
+ b.get(out);
+ return out;
+ }
+
+ private static boolean containsHeader(final HttpResponse r, final String name) {
+ return r.getFirstHeader(name) != null;
+ }
+
+ private static String headerValue(final HttpResponse r, final String name) {
+ final Header h = r.getFirstHeader(name);
+ return h != null ? h.getValue() : null;
+ }
+
+ 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());
+ }
+
+ private final class H2WebSocket implements WebSocket {
+ private final ArrayDeque queue = new ArrayDeque<>();
+ private int queuedBytes;
+
+ @Override
+ public boolean isOpen() {
+ return open.get();
+ }
+
+ @Override
+ public boolean ping(final ByteBuffer data) {
+ return enqueue(writer.ping(data), false);
+ }
+
+ @Override
+ public boolean pong(final ByteBuffer data) {
+ return enqueue(writer.pong(data), false);
+ }
+
+ @Override
+ public boolean sendText(final CharSequence data, final boolean finalFragment) {
+ if (!finalFragment) {
+ throw new UnsupportedOperationException("Fragmentation not supported in H2 client");
+ }
+ return enqueueData(FrameOpcode.TEXT, data.toString().getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Override
+ public boolean sendBinary(final ByteBuffer data, final boolean finalFragment) {
+ if (!finalFragment) {
+ throw new UnsupportedOperationException("Fragmentation not supported in H2 client");
+ }
+ final byte[] bytes = toBytes(data);
+ return enqueueData(FrameOpcode.BINARY, bytes);
+ }
+
+ @Override
+ public CompletableFuture close(final int statusCode, final String reason) {
+ if (!CloseCodec.isValidToSend(statusCode)) {
+ throw new IllegalArgumentException("Invalid close code: " + statusCode);
+ }
+ final ByteBuffer frame = writer.close(statusCode, reason);
+ enqueue(frame, true);
+ return CompletableFuture.completedFuture(null);
+ }
+
+ @Override
+ public boolean sendTextBatch(final List fragments, final boolean finalFragment) {
+ if (fragments == null || fragments.isEmpty()) {
+ throw new IllegalArgumentException("fragments must not be empty");
+ }
+ final StringBuilder sb = new StringBuilder();
+ for (final CharSequence s : fragments) {
+ if (s != null) {
+ sb.append(s);
+ }
+ }
+ return sendText(sb, finalFragment);
+ }
+
+ @Override
+ public boolean sendBinaryBatch(final List fragments, final boolean finalFragment) {
+ if (fragments == null || fragments.isEmpty()) {
+ throw new IllegalArgumentException("fragments must not be empty");
+ }
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ for (final ByteBuffer b : fragments) {
+ if (b != null) {
+ final byte[] bytes = toBytes(b);
+ out.write(bytes, 0, bytes.length);
+ }
+ }
+ return sendBinary(ByteBuffer.wrap(out.toByteArray()), finalFragment);
+ }
+
+ private boolean enqueueData(final int opcode, final byte[] payload) {
+ if (!open.get()) {
+ return false;
+ }
+ int rsv = 0;
+ byte[] out = payload;
+ if (encChain != null) {
+ final WebSocketExtensionChain.Encoded encRes = encChain.encode(out, true, true);
+ out = encRes.payload;
+ if (encRes.setRsvOnFirst) {
+ rsv = FrameHeaderBits.RSV1;
+ }
+ }
+ final ByteBuffer frame = writer.frameWithRSV(opcode, ByteBuffer.wrap(out), true, true, rsv);
+ return enqueue(frame, false);
+ }
+
+ private boolean enqueue(final ByteBuffer frame, final boolean closeAfter) {
+ if (!open.get()) {
+ return false;
+ }
+ synchronized (queue) {
+ queue.add(frame);
+ queuedBytes += frame.remaining();
+ }
+ if (dataChannel != null) {
+ dataChannel.requestOutput();
+ }
+ if (closeAfter) {
+ open.set(false);
+ }
+ return true;
+ }
+
+ int available() {
+ synchronized (queue) {
+ return queuedBytes;
+ }
+ }
+
+ void produce(final DataStreamChannel channel) throws IOException {
+ while (true) {
+ final ByteBuffer buf;
+ synchronized (queue) {
+ buf = queue.peek();
+ }
+ if (buf == null) {
+ return;
+ }
+ final int n = channel.write(buf);
+ if (n == 0) {
+ channel.requestOutput();
+ return;
+ }
+ if (!buf.hasRemaining()) {
+ synchronized (queue) {
+ queue.poll();
+ queuedBytes = Math.max(0, queuedBytes - n);
+ }
+ } else {
+ synchronized (queue) {
+ queuedBytes = Math.max(0, queuedBytes - n);
+ }
+ channel.requestOutput();
+ return;
+ }
+ }
+ }
+ }
+ }
+}
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/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/package-info.java
new file mode 100644
index 0000000000..f7b59da2f1
--- /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.core5.websocket} subpackages 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..d552bad1ef
--- /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.core5.websocket.exceptions.WebSocketProtocolException;
+import org.apache.hc.core5.websocket.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..8e24e00caa
--- /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.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;
+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..b50e4b7740
--- /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.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;
+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..f928e848af
--- /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.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;
+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..34614d5278
--- /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.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;
+
+/**
+ * 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..08be8953ae
--- /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.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;
+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/H2WebSocketEchoIT.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/H2WebSocketEchoIT.java
new file mode 100644
index 0000000000..df9891281c
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/H2WebSocketEchoIT.java
@@ -0,0 +1,162 @@
+/*
+ * ====================================================================
+ * 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;
+
+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.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+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.core5.http.ConnectionClosedException;
+import org.apache.hc.core5.util.TimeValue;
+import org.apache.hc.core5.util.Timeout;
+import org.apache.hc.core5.websocket.WebSocketHandler;
+import org.apache.hc.core5.websocket.WebSocketSession;
+import org.apache.hc.core5.websocket.server.WebSocketH2Server;
+import org.apache.hc.core5.websocket.server.WebSocketH2ServerBootstrap;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class H2WebSocketEchoIT {
+
+ private WebSocketH2Server server;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ server = WebSocketH2ServerBootstrap.bootstrap()
+ .setListenerPort(0)
+ .setCanonicalHostName("localhost")
+ .register("/echo", () -> new WebSocketHandler() {
+ @Override
+ public void onText(final WebSocketSession session, final String text) {
+ try {
+ session.sendText(text);
+ } catch (final IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ @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);
+ }
+ }
+ })
+ .create();
+ server.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (server != null) {
+ server.initiateShutdown();
+ server.stop();
+ }
+ }
+
+ @Test
+ void echoesOverHttp2ExtendedConnect() throws Exception {
+ final URI uri = URI.create("ws://localhost:" + server.getLocalPort() + "/echo");
+ final CountDownLatch done = new CountDownLatch(1);
+ final AtomicReference echo = new AtomicReference<>();
+
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .enableHttp2(true)
+ .setCloseWaitTimeout(Timeout.ofSeconds(2))
+ .build();
+
+ try (final CloseableWebSocketClient client = WebSocketClientBuilder.create()
+ .defaultConfig(cfg)
+ .build()) {
+
+ client.start();
+ client.connect(uri, new WebSocketListener() {
+ private WebSocket ws;
+
+ @Override
+ public void onOpen(final WebSocket ws) {
+ this.ws = ws;
+ ws.sendText("hello-h2", true);
+ }
+
+ @Override
+ public void onText(final CharBuffer text, final boolean last) {
+ echo.set(text.toString());
+ done.countDown();
+ ws.close(1000, "done");
+ }
+
+ @Override
+ public void onClose(final int code, final String reason) {
+ }
+
+ @Override
+ public void onError(final Throwable ex) {
+ if (!(ex instanceof ConnectionClosedException)) {
+ ex.printStackTrace(System.err);
+ }
+ done.countDown();
+ }
+ }, cfg).exceptionally(ex -> {
+ if (!(ex instanceof ConnectionClosedException)) {
+ ex.printStackTrace(System.err);
+ }
+ done.countDown();
+ return null;
+ });
+
+ assertTrue(done.await(10, TimeUnit.SECONDS), "timed out waiting for echo");
+ assertEquals("hello-h2", echo.get());
+ client.initiateShutdown();
+ client.awaitShutdown(TimeValue.ofSeconds(2));
+ }
+ }
+}
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..153a632cdd
--- /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.core5.websocket.extension.ExtensionChain;
+import org.apache.hc.core5.websocket.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/example/WebSocketEchoClient.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java
new file mode 100644
index 0000000000..34a8560ca1
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoClient.java
@@ -0,0 +1,125 @@
+/*
+ * ====================================================================
+ * 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.ofSeconds(2))
+ .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 -> {
+ ex.printStackTrace(System.err);
+ done.countDown();
+ return null;
+ });
+
+ if (!done.await(12, TimeUnit.SECONDS)) {
+ System.err.println("[TEST] Timed out waiting for echo/close");
+ System.exit(1);
+ }
+
+ 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..15a78252a9
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketEchoServer.java
@@ -0,0 +1,131 @@
+/*
+ * ====================================================================
+ * 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.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+
+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 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
+ *
+ * # 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}.
+ */
+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 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());
+ }
+
+ @Override
+ public void onText(final WebSocketSession session, final String text) {
+ try {
+ session.sendText(text);
+ } catch (final IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ @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);
+ }
+
+ @Override
+ public String selectSubprotocol(final List protocols) {
+ return protocols.isEmpty() ? null : protocols.get(0);
+ }
+ })
+ .create();
+
+ 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/example/WebSocketH2EchoClient.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoClient.java
new file mode 100644
index 0000000000..69c3c1afd7
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoClient.java
@@ -0,0 +1,113 @@
+/*
+ * ====================================================================
+ * 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.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.http.ConnectionClosedException;
+import org.apache.hc.core5.util.TimeValue;
+import org.apache.hc.core5.util.Timeout;
+
+/**
+ * Standalone H2 WebSocket echo client (RFC 8441).
+ */
+public final class WebSocketH2EchoClient {
+
+ private WebSocketH2EchoClient() {
+ }
+
+ 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()
+ .enableHttp2(true)
+ .setCloseWaitTimeout(Timeout.ofSeconds(2))
+ .build();
+
+ try (final CloseableWebSocketClient client = WebSocketClientBuilder.create()
+ .defaultConfig(cfg)
+ .build()) {
+
+ client.start();
+ client.connect(uri, new WebSocketListener() {
+ private WebSocket ws;
+
+ @Override
+ public void onOpen(final WebSocket ws) {
+ this.ws = ws;
+ ws.sendText("hello-h2", true);
+ }
+
+ @Override
+ public void onText(final CharBuffer text, final boolean last) {
+ System.out.println("[H2] echo: " + text);
+ ws.close(1000, "done");
+ }
+
+ @Override
+ public void onBinary(final ByteBuffer payload, final boolean last) {
+ System.out.println("[H2] binary: " + payload.remaining());
+ }
+
+ @Override
+ public void onClose(final int code, final String reason) {
+ done.countDown();
+ }
+
+ @Override
+ public void onError(final Throwable ex) {
+ if (!(ex instanceof ConnectionClosedException)) {
+ ex.printStackTrace(System.err);
+ }
+ done.countDown();
+ }
+ }, cfg).exceptionally(ex -> {
+ if (!(ex instanceof ConnectionClosedException)) {
+ ex.printStackTrace(System.err);
+ }
+ done.countDown();
+ return null;
+ });
+
+ if (!done.await(10, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("Timed out waiting for H2 echo");
+ }
+ client.initiateShutdown();
+ client.awaitShutdown(TimeValue.ofSeconds(2));
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoServer.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoServer.java
new file mode 100644
index 0000000000..6b96e434b3
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2EchoServer.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.example;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.concurrent.CountDownLatch;
+
+import org.apache.hc.core5.websocket.WebSocketHandler;
+import org.apache.hc.core5.websocket.WebSocketSession;
+import org.apache.hc.core5.websocket.server.WebSocketH2Server;
+import org.apache.hc.core5.websocket.server.WebSocketH2ServerBootstrap;
+
+/**
+ * Standalone H2 WebSocket echo server (RFC 8441).
+ */
+public final class WebSocketH2EchoServer {
+
+ private WebSocketH2EchoServer() {
+ }
+
+ public static void main(final String[] args) throws Exception {
+ final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080;
+ final CountDownLatch done = new CountDownLatch(1);
+
+ final WebSocketH2Server server = WebSocketH2ServerBootstrap.bootstrap()
+ .setListenerPort(port)
+ .setCanonicalHostName("localhost")
+ .register("/echo", () -> new WebSocketHandler() {
+ @Override
+ public void onText(final WebSocketSession session, final String text) {
+ try {
+ session.sendText(text);
+ } catch (final IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ @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 code, final String reason) {
+ done.countDown();
+ }
+
+ @Override
+ public void onError(final WebSocketSession session, final Exception cause) {
+ cause.printStackTrace(System.err);
+ done.countDown();
+ }
+ })
+ .create();
+
+ server.start();
+ System.out.println("[H2] echo server started at ws://localhost:" + server.getLocalPort() + "/echo");
+
+ try {
+ done.await();
+ } catch (final InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ } finally {
+ server.initiateShutdown();
+ server.stop();
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoClient.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoClient.java
new file mode 100644
index 0000000000..414bc2200b
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoClient.java
@@ -0,0 +1,123 @@
+/*
+ * ====================================================================
+ * 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.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import javax.net.ssl.SSLContext;
+
+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.http.ConnectionClosedException;
+import org.apache.hc.core5.http2.ssl.H2ClientTlsStrategy;
+import org.apache.hc.core5.ssl.SSLContexts;
+import org.apache.hc.core5.util.TimeValue;
+import org.apache.hc.core5.util.Timeout;
+
+/**
+ * Standalone H2 WebSocket echo client over TLS (RFC 8441, wss://).
+ */
+public final class WebSocketH2TlsEchoClient {
+
+ private WebSocketH2TlsEchoClient() {
+ }
+
+ public static void main(final String[] args) throws Exception {
+ final URI uri = URI.create(args.length > 0 ? args[0] : "wss://localhost:8443/echo");
+ final CountDownLatch done = new CountDownLatch(1);
+
+ final SSLContext sslContext = SSLContexts.custom()
+ .loadTrustMaterial(WebSocketH2TlsEchoClient.class.getResource("/test.keystore"),
+ "nopassword".toCharArray())
+ .build();
+
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .enableHttp2(true)
+ .setCloseWaitTimeout(Timeout.ofSeconds(2))
+ .build();
+
+ try (final CloseableWebSocketClient client = WebSocketClientBuilder.create()
+ .setTlsStrategy(new H2ClientTlsStrategy(sslContext))
+ .defaultConfig(cfg)
+ .build()) {
+
+ client.start();
+ client.connect(uri, new WebSocketListener() {
+ private WebSocket ws;
+
+ @Override
+ public void onOpen(final WebSocket ws) {
+ this.ws = ws;
+ ws.sendText("hello-h2-tls", true);
+ }
+
+ @Override
+ public void onText(final CharBuffer text, final boolean last) {
+ System.out.println("[H2/TLS] echo: " + text);
+ ws.close(1000, "done");
+ }
+
+ @Override
+ public void onBinary(final ByteBuffer payload, final boolean last) {
+ System.out.println("[H2/TLS] binary: " + payload.remaining());
+ }
+
+ @Override
+ public void onClose(final int code, final String reason) {
+ done.countDown();
+ }
+
+ @Override
+ public void onError(final Throwable ex) {
+ if (!(ex instanceof ConnectionClosedException)) {
+ ex.printStackTrace(System.err);
+ }
+ done.countDown();
+ }
+ }, cfg).exceptionally(ex -> {
+ if (!(ex instanceof ConnectionClosedException)) {
+ ex.printStackTrace(System.err);
+ }
+ done.countDown();
+ return null;
+ });
+
+ if (!done.await(10, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("Timed out waiting for H2/TLS echo");
+ }
+ client.initiateShutdown();
+ client.awaitShutdown(TimeValue.ofSeconds(2));
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoServer.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoServer.java
new file mode 100644
index 0000000000..9d0852d1ac
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoServer.java
@@ -0,0 +1,117 @@
+/*
+ * ====================================================================
+ * 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 java.util.concurrent.CountDownLatch;
+
+import javax.net.ssl.SSLContext;
+
+import org.apache.hc.core5.http2.ssl.H2ServerTlsStrategy;
+import org.apache.hc.core5.ssl.SSLContexts;
+import org.apache.hc.core5.websocket.WebSocketHandler;
+import org.apache.hc.core5.websocket.WebSocketSession;
+import org.apache.hc.core5.websocket.server.WebSocketH2Server;
+import org.apache.hc.core5.websocket.server.WebSocketH2ServerBootstrap;
+
+/**
+ * Standalone H2 WebSocket echo server over TLS (RFC 8441, wss://).
+ */
+public final class WebSocketH2TlsEchoServer {
+
+ private WebSocketH2TlsEchoServer() {
+ }
+
+ public static void main(final String[] args) throws Exception {
+ final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8443;
+ final CountDownLatch done = new CountDownLatch(1);
+
+ final SSLContext sslContext = SSLContexts.custom()
+ .loadTrustMaterial(WebSocketH2TlsEchoServer.class.getResource("/test.keystore"),
+ "nopassword".toCharArray())
+ .loadKeyMaterial(WebSocketH2TlsEchoServer.class.getResource("/test.keystore"),
+ "nopassword".toCharArray(), "nopassword".toCharArray())
+ .build();
+
+ final WebSocketH2Server server = WebSocketH2ServerBootstrap.bootstrap()
+ .setListenerPort(port)
+ .setCanonicalHostName("localhost")
+ .setTlsStrategy(new H2ServerTlsStrategy(sslContext))
+ .register("/echo", () -> new WebSocketHandler() {
+ @Override
+ public void onText(final WebSocketSession session, final String text) {
+ try {
+ session.sendText(text);
+ } catch (final java.io.IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ @Override
+ public void onBinary(final WebSocketSession session, final ByteBuffer data) {
+ try {
+ session.sendBinary(data);
+ } catch (final java.io.IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ @Override
+ public void onPing(final WebSocketSession session, final ByteBuffer data) {
+ try {
+ session.sendPong(data);
+ } catch (final java.io.IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ @Override
+ public void onClose(final WebSocketSession session, final int code, final String reason) {
+ done.countDown();
+ }
+
+ @Override
+ public void onError(final WebSocketSession session, final Exception cause) {
+ cause.printStackTrace(System.err);
+ done.countDown();
+ }
+ })
+ .create();
+
+ server.start();
+ System.out.println("[H2/TLS] echo server started at wss://localhost:" + server.getLocalPort() + "/echo");
+
+ try {
+ done.await();
+ } catch (final InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ } finally {
+ server.initiateShutdown();
+ server.stop();
+ }
+ }
+}
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..e16e0c7f2b
--- /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.core5.websocket.exceptions.WebSocketProtocolException;
+import org.apache.hc.core5.websocket.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..41cf9a4e66
--- /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.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;
+
+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/httpclient5-websocket/src/test/resources/test.keystore b/httpclient5-websocket/src/test/resources/test.keystore
new file mode 100644
index 0000000000..f8d5ace1ad
Binary files /dev/null and b/httpclient5-websocket/src/test/resources/test.keystore differ
diff --git a/pom.xml b/pom.xml
index 1a0d7291c6..620ff7cda7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -99,6 +99,11 @@
httpcore5-h2
${httpcore.version}