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..6b0a96afce
--- /dev/null
+++ b/httpclient5-websocket/pom.xml
@@ -0,0 +1,108 @@
+
+
+ 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-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..a25acb10c5
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/api/WebSocketClientConfig.java
@@ -0,0 +1,613 @@
+/*
+ * ====================================================================
+ * 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 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
+ private final boolean directBuffers;
+
+ // Behavior
+ private final boolean autoPong;
+ private final Timeout closeWaitTimeout;
+ private final long maxMessageSize;
+ private final boolean http2Enabled;
+
+ // Outbound queue limits
+ private final int maxOutboundControlQueue;
+ private final long maxOutboundDataBytes;
+
+ 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 boolean directBuffers,
+ final boolean autoPong,
+ final Timeout closeWaitTimeout,
+ final long maxMessageSize,
+ final int maxOutboundControlQueue,
+ final long maxOutboundDataBytes,
+ 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.directBuffers = directBuffers;
+ this.autoPong = autoPong;
+ this.closeWaitTimeout = Args.notNull(closeWaitTimeout, "closeWaitTimeout");
+ this.maxMessageSize = maxMessageSize;
+ this.maxOutboundControlQueue = maxOutboundControlQueue;
+ this.maxOutboundDataBytes = maxOutboundDataBytes;
+ 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;
+ }
+
+ /**
+ * Whether direct byte buffers are preferred for internal I/O.
+ *
+ * @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;
+ }
+
+ /**
+ * Upper bound for the total number of bytes queued for outbound data frames.
+ *
+ * When this limit is exceeded, {@code sendText/sendBinary} return {@code false}
+ * and the data frame is rejected. A value of {@code 0} disables the limit.
+ *
+ * @return max queued bytes for outbound data frames
+ * @since 5.7
+ */
+ public long getMaxOutboundDataBytes() {
+ return maxOutboundDataBytes;
+ }
+
+ /**
+ * 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 boolean directBuffers = true;
+
+ private boolean autoPong = true;
+ private Timeout closeWaitTimeout = Timeout.ofSeconds(10);
+ private long maxMessageSize = 8L * 1024 * 1024;
+
+ private int maxOutboundControlQueue = 256;
+ private long maxOutboundDataBytes = 16L * 1024 * 1024;
+ 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;
+ }
+
+ /**
+ * Enables or disables the use of direct buffers for internal I/O.
+ *
+ * @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;
+ }
+
+ /**
+ * Sets the maximum number of bytes that can be queued for outbound data frames.
+ *
+ * When the limit is exceeded, {@code sendText/sendBinary} return {@code false}.
+ * A value of {@code 0} disables the limit.
+ *
+ * @param v max queued bytes for outbound data frames
+ * @return this builder
+ * @since 5.7
+ */
+ public Builder setMaxOutboundDataBytes(final long v) {
+ this.maxOutboundDataBytes = 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 (maxMessageSize <= 0) {
+ throw new IllegalArgumentException("maxMessageSize > 0");
+ }
+ if (maxOutboundControlQueue <= 0) {
+ throw new IllegalArgumentException("maxOutboundControlQueue > 0");
+ }
+ if (maxOutboundDataBytes < 0) {
+ throw new IllegalArgumentException("maxOutboundDataBytes >= 0");
+ }
+ return new WebSocketClientConfig(
+ connectTimeout, subprotocols,
+ perMessageDeflateEnabled, offerServerNoContextTakeover, offerClientNoContextTakeover,
+ offerClientMaxWindowBits, offerServerMaxWindowBits,
+ maxFrameSize, outgoingChunkSize, maxFramesPerTick,
+ directBuffers,
+ autoPong, closeWaitTimeout, maxMessageSize,
+ maxOutboundControlQueue,
+ maxOutboundDataBytes,
+ 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..8de4cfdc4d
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilder.java
@@ -0,0 +1,480 @@
+/*
+ * ====================================================================
+ * 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 int maxPendingCommandsPerConnection;
+
+ 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 maximum number of pending commands per connection.
+ *
+ * @param maxPendingCommandsPerConnection maximum pending commands; values < 0
+ * cause the default of {@code 0} to be used.
+ * @return this builder.
+ */
+ public WebSocketClientBuilder setMaxPendingCommandsPerConnection(final int maxPendingCommandsPerConnection) {
+ this.maxPendingCommandsPerConnection = maxPendingCommandsPerConnection;
+ 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,
+ Math.max(maxPendingCommandsPerConnection, 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..a25df57c2a
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/AbstractWebSocketClient.java
@@ -0,0 +1,124 @@
+/*
+ * ====================================================================
+ * 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];
+ final int threads = Math.max(1, 1 + this.extraRequesters.length);
+ this.executorService = Executors.newFixedThreadPool(threads, 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) {
+ executorService.execute(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..d314ef3514
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/InternalWebSocketClientBase.java
@@ -0,0 +1,209 @@
+/*
+ * ====================================================================
+ * 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.CancellationException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+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) {
+ final CompletableFuture result = new CompletableFuture<>();
+ final AtomicBoolean h2Opened = new AtomicBoolean(false);
+ final AtomicReference suppressedError = new AtomicReference<>();
+ final WebSocketListener h2Listener = new WebSocketListener() {
+ @Override
+ public void onOpen(final WebSocket webSocket) {
+ h2Opened.set(true);
+ listener.onOpen(webSocket);
+ }
+
+ @Override
+ public void onText(final java.nio.CharBuffer data, final boolean last) {
+ listener.onText(data, last);
+ }
+
+ @Override
+ public void onBinary(final java.nio.ByteBuffer data, final boolean last) {
+ listener.onBinary(data, last);
+ }
+
+ @Override
+ public void onPing(final java.nio.ByteBuffer data) {
+ listener.onPing(data);
+ }
+
+ @Override
+ public void onPong(final java.nio.ByteBuffer data) {
+ listener.onPong(data);
+ }
+
+ @Override
+ public void onClose(final int statusCode, final String reason) {
+ listener.onClose(statusCode, reason);
+ }
+
+ @Override
+ public void onError(final Throwable cause) {
+ if (!h2Opened.get() && !result.isDone()) {
+ suppressedError.compareAndSet(null, cause);
+ return;
+ }
+ listener.onError(cause);
+ }
+ };
+
+ h2.connect(uri, h2Listener, cfg, context).whenComplete((ws, ex) -> {
+ if (ex == null) {
+ result.complete(ws);
+ return;
+ }
+ final Throwable cause = unwrap(ex);
+ if (!h2Opened.get() && shouldFallbackToH1(cause)) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("H2 WebSocket attempt failed, falling back to HTTP/1.1: {}", cause.getMessage());
+ }
+ h1.connect(uri, listener, cfg, context).whenComplete((ws2, ex2) -> {
+ if (ex2 == null) {
+ result.complete(ws2);
+ } else {
+ result.completeExceptionally(unwrap(ex2));
+ }
+ });
+ } else {
+ if (!h2Opened.get()) {
+ final Throwable suppressed = suppressedError.get();
+ if (suppressed != null) {
+ listener.onError(suppressed);
+ }
+ }
+ result.completeExceptionally(cause);
+ }
+ });
+ return result;
+ }
+ return h1.connect(uri, listener, cfg, context);
+ }
+
+ private static Throwable unwrap(final Throwable ex) {
+ if (ex instanceof CompletionException) {
+ return ex.getCause() != null ? ex.getCause() : ex;
+ }
+ return ex;
+ }
+
+ private static boolean shouldFallbackToH1(final Throwable ex) {
+ if (ex instanceof CancellationException) {
+ return false;
+ }
+ return !(ex instanceof IllegalArgumentException);
+ }
+
+ @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..4ba3d57afe
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http1UpgradeProtocol.java
@@ -0,0 +1,501 @@
+/*
+ * ====================================================================
+ * 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.security.SecureRandom;
+import java.util.Base64;
+import java.util.List;
+import java.util.StringJoiner;
+import java.util.concurrent.CompletableFuture;
+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 static final SecureRandom NONCE_RANDOM = new SecureRandom();
+
+ 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];
+ NONCE_RANDOM.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..6b6504622d
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocol.java
@@ -0,0 +1,894 @@
+/*
+ * ====================================================================
+ * 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.CharBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayDeque;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.locks.ReentrantLock;
+
+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.concurrent.DefaultThreadFactory;
+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.impl.nio.bootstrap.H2MultiplexingRequester;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.Timeout;
+import org.apache.hc.core5.websocket.WebSocketConstants;
+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.
+ */
+@Internal
+public final class Http2ExtendedConnectProtocol implements WebSocketProtocolStrategy {
+
+ private static final ScheduledExecutorService CLOSE_TIMER =
+ Executors.newSingleThreadScheduledExecutor(new DefaultThreadFactory("ws-h2-close", true));
+
+ 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 BasicHttpRequest req = new BasicHttpRequest(Method.CONNECT.name(), target, fullPath);
+ req.addHeader(WebSocketConstants.PSEUDO_PROTOCOL, "websocket");
+ req.addHeader(WebSocketConstants.SEC_WEBSOCKET_VERSION_LOWER, "13");
+
+ 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(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL_LOWER, 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");
+ }
+ // Your implementation supports only 15 safely.
+ if (cfg.getOfferClientMaxWindowBits() != null && cfg.getOfferClientMaxWindowBits() == 15) {
+ ext.append("; client_max_window_bits=15");
+ }
+ if (cfg.getOfferServerMaxWindowBits() != null) {
+ ext.append("; server_max_window_bits=").append(cfg.getOfferServerMaxWindowBits());
+ }
+ req.addHeader(WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS_LOWER, ext.toString());
+ }
+
+ final Timeout timeout = cfg.getConnectTimeout() != null ? cfg.getConnectTimeout() : Timeout.ofSeconds(10);
+ requester.execute(target, new H2WebSocketExchangeHandler(req, listener, cfg, f), null, timeout, context);
+ return f;
+ }
+
+ private static final class H2WebSocketExchangeHandler implements AsyncClientExchangeHandler {
+
+ private final BasicHttpRequest request;
+ 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 final AtomicLong assemblingBytesSize = new AtomicLong();
+
+ private volatile DataStreamChannel dataChannel;
+
+ H2WebSocketExchangeHandler(
+ final BasicHttpRequest request,
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfg,
+ final CompletableFuture future) {
+ this.request = request;
+ 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) {
+ failFuture(new IllegalStateException("Unexpected status: " + response.getCode()));
+ return;
+ }
+
+ try {
+ final String selectedProto = headerValue(response, WebSocketConstants.SEC_WEBSOCKET_PROTOCOL_LOWER);
+ if (selectedProto != null && !selectedProto.isEmpty()) {
+ final List offered = cfg.getSubprotocols();
+ if (offered == null || !offered.contains(selectedProto)) {
+ throw new ProtocolException("Server selected unsupported subprotocol: " + selectedProto);
+ }
+ }
+
+ final ExtensionChain chain = Http1UpgradeProtocol.buildExtensionChain(
+ cfg, headerValue(response, WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS_LOWER));
+ this.encChain = chain.isEmpty() ? null : chain.newEncodeChain();
+ this.decChain = chain.isEmpty() ? null : chain.newDecodeChain();
+ this.decoder = new WebSocketFrameDecoder(cfg.getMaxFrameSize(), chain.isEmpty(), false);
+
+ if (!future.isDone()) {
+ future.complete(webSocket);
+ }
+ listener.onOpen(webSocket);
+ } catch (final Exception ex) {
+ failFuture(ex);
+ }
+ }
+
+ @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() || decoder == null) {
+ return;
+ }
+ appendToInbuf(src);
+ inbuf.flip();
+ for (; ; ) {
+ final boolean has;
+ try {
+ has = decoder.decode(inbuf);
+ } catch (final RuntimeException ex) {
+ listener.onError(ex);
+ failFuture(ex);
+ open.set(false);
+ return;
+ }
+ if (!has) {
+ break;
+ }
+ handleFrame();
+ if (!open.get()) {
+ break;
+ }
+ }
+ inbuf.compact();
+ }
+
+ @Override
+ public void streamEnd(final List extends Header> trailers) throws HttpException, IOException {
+ open.set(false);
+ if (!future.isDone()) {
+ failFuture(new ProtocolException("Stream ended before handshake completed"));
+ } else if (!webSocket.isCloseReceived()) {
+ listener.onClose(1006, "");
+ }
+ }
+
+ @Override
+ public int available() {
+ if (dataChannel == null && outputPrimed.compareAndSet(false, true)) {
+ return 1;
+ }
+ return webSocket.endStreamPending() ? 1 : 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);
+ failFuture(cause);
+ open.set(false);
+ }
+
+ @Override
+ public void releaseResources() {
+ if (!future.isDone()) {
+ failFuture(new ProtocolException("WebSocket exchange released"));
+ }
+ open.set(false);
+ }
+
+ @Override
+ public void cancel() {
+ if (!future.isDone()) {
+ failFuture(new ProtocolException("WebSocket exchange cancelled"));
+ }
+ 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) {
+ final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "RSV2/RSV3 not supported");
+ listener.onError(ex);
+ webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage());
+ failFuture(ex);
+ open.set(false);
+ return;
+ }
+ if (r1 && decChain == null) {
+ final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "RSV1 without negotiated extension");
+ listener.onError(ex);
+ webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage());
+ failFuture(ex);
+ open.set(false);
+ return;
+ }
+ if (FrameOpcode.isControl(op)) {
+ if (!fin) {
+ final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "fragmented control frame");
+ listener.onError(ex);
+ webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage());
+ failFuture(ex);
+ open.set(false);
+ return;
+ }
+ if (payload.remaining() > 125) {
+ final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "control frame too large");
+ listener.onError(ex);
+ webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage());
+ failFuture(ex);
+ 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) {
+ final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "Invalid close payload length");
+ listener.onError(ex);
+ webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage());
+ failFuture(ex);
+ open.set(false);
+ return;
+ } else if (payload.remaining() >= 2) {
+ final ByteBuffer dup = payload.slice();
+ code = CloseCodec.readCloseCode(dup);
+ if (!CloseCodec.isValidToReceive(code)) {
+ final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "Invalid close code");
+ listener.onError(ex);
+ webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage());
+ failFuture(ex);
+ open.set(false);
+ return;
+ }
+ if (dup.hasRemaining()) {
+ try {
+ reason = decodeTextStrict(dup.asReadOnlyBuffer());
+ } catch (final WebSocketProtocolException ex) {
+ listener.onError(ex);
+ webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage());
+ failFuture(ex);
+ open.set(false);
+ return;
+ }
+ }
+ }
+ listener.onClose(code, reason);
+ if (!webSocket.isCloseSent()) {
+ final int replyCode = code == 1005 ? 1000 : code;
+ final String replyReason = code == 1005 ? "" : reason;
+ webSocket.sendCloseIfNeeded(replyCode, replyReason);
+ }
+ webSocket.onCloseReceived();
+ break;
+
+ case FrameOpcode.TEXT:
+ case FrameOpcode.BINARY:
+ if (assemblingOpcode != -1) {
+ final WebSocketProtocolException ex =
+ new WebSocketProtocolException(1002, "New data frame while fragmented message in progress");
+ listener.onError(ex);
+ webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage());
+ failFuture(ex);
+ open.set(false);
+ return;
+ }
+ if (isTooLarge(payload.remaining())) {
+ final WebSocketProtocolException ex = new WebSocketProtocolException(1009, "Message too big");
+ listener.onError(ex);
+ webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage());
+ failFuture(ex);
+ open.set(false);
+ return;
+ }
+ if (!fin) {
+ assemblingOpcode = op;
+ assemblingCompressed = r1 && decChain != null;
+ assemblingBytes = new ByteArrayOutputStream(Math.max(1024, payload.remaining()));
+ assemblingBytesSize.set(0);
+ try {
+ appendPayload(payload);
+ } catch (final WebSocketProtocolException ex) {
+ listener.onError(ex);
+ webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage());
+ failFuture(ex);
+ open.set(false);
+ }
+ return;
+ }
+ deliverSingle(op, payload, r1);
+ break;
+
+ case FrameOpcode.CONT:
+ if (assemblingOpcode == -1) {
+ final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "Unexpected continuation frame");
+ listener.onError(ex);
+ webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage());
+ failFuture(ex);
+ open.set(false);
+ return;
+ }
+ try {
+ appendPayload(payload);
+ } catch (final WebSocketProtocolException ex) {
+ listener.onError(ex);
+ webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage());
+ failFuture(ex);
+ open.set(false);
+ return;
+ }
+ if (fin) {
+ final ByteBuffer full = ByteBuffer.wrap(assemblingBytes.toByteArray());
+ final int opcode = assemblingOpcode;
+ final boolean compressed = assemblingCompressed;
+ assemblingOpcode = -1;
+ assemblingCompressed = false;
+ assemblingBytes = null;
+ assemblingBytesSize.set(0);
+ deliverSingle(opcode, full, compressed);
+ }
+ break;
+
+ default:
+ final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "Unsupported opcode: " + op);
+ listener.onError(ex);
+ webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage());
+ failFuture(ex);
+ open.set(false);
+ }
+ }
+
+ private void deliverSingle(final int opcode, final ByteBuffer payload, final boolean rsv1) {
+ ByteBuffer data = payload.asReadOnlyBuffer();
+ if (rsv1 && decChain != null) {
+ try {
+ final byte[] decoded = decChain.decode(toBytes(data));
+ if (isTooLarge(decoded.length)) {
+ throw new WebSocketProtocolException(1009, "Message too big");
+ }
+ data = ByteBuffer.wrap(decoded);
+ } catch (final Exception ex) {
+ // Treat inflate failures as protocol errors.
+ final WebSocketProtocolException wex = ex instanceof WebSocketProtocolException
+ ? (WebSocketProtocolException) ex
+ : new WebSocketProtocolException(1002, "Bad compressed payload");
+ listener.onError(ex);
+ webSocket.sendCloseIfNeeded(wex.closeCode, wex.getMessage());
+ failFuture(ex);
+ open.set(false);
+ return;
+ }
+ }
+ if (opcode == FrameOpcode.TEXT) {
+ try {
+ listener.onText(CharBuffer.wrap(decodeTextStrict(data)), true);
+ } catch (final WebSocketProtocolException ex) {
+ listener.onError(ex);
+ webSocket.sendCloseIfNeeded(ex.closeCode, ex.getMessage());
+ failFuture(ex);
+ open.set(false);
+ }
+ } else {
+ listener.onBinary(data, true);
+ }
+ }
+
+ private void appendPayload(final ByteBuffer payload) {
+ if (assemblingBytes == null) {
+ assemblingBytes = new ByteArrayOutputStream();
+ }
+ if (isTooLarge(assemblingBytesSize.get() + payload.remaining())) {
+ throw new WebSocketProtocolException(1009, "Message too big");
+ }
+ final byte[] tmp = toBytes(payload);
+ assemblingBytes.write(tmp, 0, tmp.length);
+ assemblingBytesSize.addAndGet(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 String headerValue(final HttpResponse r, final String name) {
+ final Header h = r.getFirstHeader(name);
+ return h != null ? h.getValue() : null;
+ }
+
+ private static String decodeTextStrict(final ByteBuffer payload) throws WebSocketProtocolException {
+ try {
+ final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT);
+ return decoder.decode(payload.asReadOnlyBuffer()).toString();
+ } catch (final CharacterCodingException ex) {
+ throw new WebSocketProtocolException(1007, "Invalid UTF-8 payload");
+ }
+ }
+
+ private void failFuture(final Exception ex) {
+ if (!future.isDone()) {
+ future.completeExceptionally(ex);
+ }
+ }
+
+ private boolean isTooLarge(final long size) {
+ final long max = cfg.getMaxMessageSize();
+ return max > 0 && size > max;
+ }
+
+ private final class H2WebSocket implements WebSocket {
+
+ private final ArrayDeque queue = new ArrayDeque<>();
+ private int queuedBytes;
+ private final ReentrantLock queueLock = new ReentrantLock();
+ private final ReentrantLock sendLock = new ReentrantLock();
+ private int outOpcode = -1;
+ private final int outChunk = Math.max(256, cfg.getOutgoingChunkSize());
+
+ private final AtomicBoolean closeSent = new AtomicBoolean(false);
+ private volatile boolean endStreamAfterClose;
+ private volatile boolean closeReceived;
+ private volatile ScheduledFuture> closeTimeoutFuture;
+
+ @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 (closeSent.get()) {
+ 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 (closeSent.get()) {
+ return false;
+ }
+ return sendData(FrameOpcode.BINARY, data, finalFragment);
+ }
+
+ @Override
+ public CompletableFuture close(final int statusCode, final String reason) {
+ if (!CloseCodec.isValidToSend(statusCode)) {
+ throw new IllegalArgumentException("Invalid close code: " + statusCode);
+ }
+ final CompletableFuture cf = new CompletableFuture<>();
+ if (!open.get()) {
+ cf.completeExceptionally(new IllegalStateException("WebSocket is closed"));
+ return cf;
+ }
+ final boolean ok = sendCloseIfNeeded(statusCode, reason);
+ if (ok) {
+ cf.complete(null);
+ } else {
+ cf.completeExceptionally(new IllegalStateException("Close could not be initiated"));
+ }
+ return cf;
+ }
+
+ @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 sendData(final int opcode, final ByteBuffer data, final boolean fin) {
+ sendLock.lock();
+ try {
+ if (!open.get()) {
+ return false;
+ }
+ int currentOpcode = outOpcode == -1 ? opcode : FrameOpcode.CONT;
+ if (outOpcode == -1) {
+ outOpcode = opcode;
+ }
+ final ByteBuffer ro = data.asReadOnlyBuffer();
+ boolean ok = true;
+ boolean firstFragment = currentOpcode != FrameOpcode.CONT;
+
+ while (ro.hasRemaining()) {
+ final int n = Math.min(ro.remaining(), 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 (!enqueueDataFrame(currentOpcode, slice, lastSlice, firstFragment)) {
+ ok = false;
+ break;
+ }
+ currentOpcode = FrameOpcode.CONT;
+ firstFragment = false;
+ }
+
+ if (fin || !ok) {
+ outOpcode = -1;
+ }
+ return ok;
+ } finally {
+ sendLock.unlock();
+ }
+ }
+
+ private boolean enqueueDataFrame(final int opcode, final ByteBuffer payload, final boolean fin, final boolean firstFragment) {
+ if (!open.get()) {
+ return false;
+ }
+ byte[] out = toBytes(payload);
+ int rsv = 0;
+ if (encChain != null) {
+ final WebSocketExtensionChain.Encoded encRes = encChain.encode(out, firstFragment, fin);
+ out = encRes.payload;
+ if (encRes.setRsvOnFirst && firstFragment) {
+ rsv = FrameHeaderBits.RSV1;
+ }
+ }
+ final ByteBuffer frame = writer.frameWithRSV(opcode, ByteBuffer.wrap(out), fin, true, rsv);
+ return enqueue(frame, false);
+ }
+
+ private boolean enqueue(final ByteBuffer frame, final boolean closeAfter) {
+ if (!open.get()) {
+ return false;
+ }
+ queueLock.lock();
+ try {
+ if (closeAfter) {
+ queue.clear();
+ queuedBytes = 0;
+ } else {
+ final long maxBytes = cfg.getMaxOutboundDataBytes() > 0
+ ? cfg.getMaxOutboundDataBytes()
+ : (long) cfg.getMaxOutboundControlQueue() * (long) cfg.getMaxFrameSize();
+ if (maxBytes > 0 && (long) queuedBytes + (long) frame.remaining() > maxBytes) {
+ return false;
+ }
+ }
+ queue.add(frame);
+ queuedBytes += frame.remaining();
+ } finally {
+ queueLock.unlock();
+ }
+ if (closeAfter) {
+ closeSent.set(true);
+ scheduleCloseTimeout();
+ }
+ final DataStreamChannel ch = dataChannel;
+ if (ch != null) {
+ ch.requestOutput();
+ }
+ return true;
+ }
+
+ int available() {
+ queueLock.lock();
+ try {
+ return queuedBytes;
+ } finally {
+ queueLock.unlock();
+ }
+ }
+
+ boolean endStreamPending() {
+ return endStreamAfterClose;
+ }
+
+ void produce(final DataStreamChannel channel) throws IOException {
+ for (; ; ) {
+ final ByteBuffer buf;
+ queueLock.lock();
+ try {
+ buf = queue.peek();
+ } finally {
+ queueLock.unlock();
+ }
+ if (buf == null) {
+ if (endStreamAfterClose) {
+ endStreamAfterClose = false;
+ channel.endStream(null);
+ }
+ return;
+ }
+ final int n = channel.write(buf);
+ if (n == 0) {
+ channel.requestOutput();
+ return;
+ }
+ queueLock.lock();
+ try {
+ queuedBytes = Math.max(0, queuedBytes - n);
+ if (!buf.hasRemaining()) {
+ queue.poll();
+ } else {
+ channel.requestOutput();
+ return;
+ }
+ } finally {
+ queueLock.unlock();
+ }
+ }
+ }
+
+ boolean isCloseSent() {
+ return closeSent.get();
+ }
+
+ boolean isCloseReceived() {
+ return closeReceived;
+ }
+
+ boolean sendCloseIfNeeded(final int code, final String reason) {
+ if (closeSent.get()) {
+ return true;
+ }
+ return enqueue(writer.close(code, reason), true);
+ }
+
+ void onCloseReceived() {
+ closeReceived = true;
+ open.set(false);
+ cancelCloseTimeout();
+ endStreamAfterClose = true;
+ final DataStreamChannel ch = dataChannel;
+ if (ch != null) {
+ ch.requestOutput();
+ }
+ }
+
+ private void scheduleCloseTimeout() {
+ final Timeout t = cfg.getCloseWaitTimeout();
+ if (t == null || t.isDisabled()) {
+ return;
+ }
+ if (closeTimeoutFuture != null) {
+ closeTimeoutFuture.cancel(false);
+ }
+ closeTimeoutFuture = CLOSE_TIMER.schedule(() -> {
+ open.set(false);
+ endStreamAfterClose = true;
+ final DataStreamChannel ch = dataChannel;
+ if (ch != null) {
+ ch.requestOutput();
+ }
+ }, t.toMilliseconds(), TimeUnit.MILLISECONDS);
+ }
+
+ private void cancelCloseTimeout() {
+ if (closeTimeoutFuture != null) {
+ closeTimeoutFuture.cancel(false);
+ closeTimeoutFuture = null;
+ }
+ }
+ }
+ }
+}
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..849d3d7494
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketInbound.java
@@ -0,0 +1,440 @@
+/*
+ * ====================================================================
+ * 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) {
+ }
+ }
+ 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;
+ }
+
+ final int size = Math.max(8192, s.outChunk);
+
+ ByteBuffer rb = s.readBuf;
+ if (rb == null) {
+ rb = s.cfg.isDirectBuffers()
+ ? ByteBuffer.allocateDirect(size)
+ : ByteBuffer.allocate(size);
+ s.readBuf = rb;
+ }
+
+ if (src != null && src.hasRemaining()) {
+ appendToInbuf(src);
+ }
+
+ int n;
+ do {
+ 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, false));
+ }
+ 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, false));
+ } 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..6c6fefb132
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketOutbound.java
@@ -0,0 +1,586 @@
+/*
+ * ====================================================================
+ * 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;
+ final boolean dataFrame;
+ final int size;
+
+ OutFrame(final ByteBuffer buf, final boolean pooled, final boolean dataFrame) {
+ this.buf = buf;
+ this.pooled = pooled;
+ this.dataFrame = dataFrame;
+ this.size = buf.remaining();
+ }
+ }
+
+ 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()) {
+ if (s.activeWrite.dataFrame) {
+ s.dataQueuedBytes.addAndGet(-s.activeWrite.size);
+ }
+ 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) {
+ // No-op: buffers are not pooled.
+ }
+
+ 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;
+ }
+ final long limit = s.cfg.getMaxOutboundDataBytes();
+ final long newSize = s.dataQueuedBytes.addAndGet(frame.size);
+ if (limit > 0 && newSize > limit) {
+ s.dataQueuedBytes.addAndGet(-frame.size);
+ 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 boolean dataFrame) {
+ 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 = s.cfg.isDirectBuffers()
+ ? ByteBuffer.allocateDirect(totalSize)
+ : ByteBuffer.allocate(totalSize);
+ final boolean 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, dataFrame);
+ }
+
+ // package-private for outbound compression (RSV1 when negotiated)
+ OutFrame pooledFrameWithRsv(final int opcode, final ByteBuffer payload, final boolean fin, final int rsvBits, final boolean dataFrame) {
+ 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 = s.cfg.isDirectBuffers()
+ ? ByteBuffer.allocateDirect(totalSize)
+ : ByteBuffer.allocate(totalSize);
+ final boolean pooled = false;
+
+ buf.clear();
+ s.writer.frameIntoWithRSV(opcode, ro, fin, true, rsvBits, buf);
+ buf.flip();
+
+ return new OutFrame(buf, pooled, dataFrame);
+ }
+
+ // 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 = s.cfg.isDirectBuffers()
+ ? ByteBuffer.allocateDirect(totalSize)
+ : ByteBuffer.allocate(totalSize);
+ final boolean pooled = false;
+
+ buf.clear();
+ s.writer.frameInto(FrameOpcode.CLOSE, ro, true, true, buf);
+ buf.flip();
+
+ return new OutFrame(buf, pooled, false);
+ }
+
+ // 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) {
+ if (f.dataFrame) {
+ s.dataQueuedBytes.addAndGet(-f.size);
+ }
+ 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, false));
+ }
+
+ @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, false));
+ }
+
+ @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) {
+ s.writeLock.lock();
+ try {
+ 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, true))) {
+ 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;
+ } finally {
+ s.writeLock.unlock();
+ }
+ }
+
+
+ @Override
+ public boolean sendTextBatch(final List fragments, final boolean finalFragment) {
+ if (!s.open.get() || s.closeSent.get() || fragments == null || fragments.isEmpty()) {
+ return false;
+ }
+ s.writeLock.lock();
+ try {
+ 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;
+ } finally {
+ s.writeLock.unlock();
+ }
+ }
+
+ @Override
+ public boolean sendBinaryBatch(final List fragments, final boolean finalFragment) {
+ if (!s.open.get() || s.closeSent.get() || fragments == null || fragments.isEmpty()) {
+ return false;
+ }
+ s.writeLock.lock();
+ try {
+ 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;
+ } finally {
+ s.writeLock.unlock();
+ }
+ }
+
+ @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, false))) {
+ 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, true);
+ }
+ 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, true);
+ }
+
+ 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..40f292ab04
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/client5/http/websocket/transport/WebSocketSessionState.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.transport;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.locks.ReentrantLock;
+
+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.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 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;
+ final AtomicLong dataQueuedBytes = new AtomicLong();
+
+ // 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 ReentrantLock writeLock = new ReentrantLock();
+
+ // 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);
+ this.readBuf = cfg.isDirectBuffers() ? ByteBuffer.allocateDirect(poolBufSize) : ByteBuffer.allocate(poolBufSize);
+ }
+}
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/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtension.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtension.java
new file mode 100644
index 0000000000..0a4c3a0e07
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtension.java
@@ -0,0 +1,181 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.zip.Deflater;
+import java.util.zip.Inflater;
+
+public final class PerMessageDeflateExtension implements WebSocketExtension {
+
+ private static final byte[] TAIL = new byte[]{0x00, 0x00, (byte) 0xFF, (byte) 0xFF};
+ private static final int MIN_WINDOW_BITS = 8;
+ private static final int MAX_WINDOW_BITS = 15;
+
+ private final boolean serverNoContextTakeover;
+ private final boolean clientNoContextTakeover;
+ private final Integer clientMaxWindowBits;
+ private final Integer serverMaxWindowBits;
+
+ private final Inflater inflater = new Inflater(true);
+ private final Deflater deflater = new Deflater(Deflater.DEFAULT_COMPRESSION, true);
+ private boolean decodingMessage;
+
+ public PerMessageDeflateExtension() {
+ this(false, false, MAX_WINDOW_BITS, MAX_WINDOW_BITS);
+ }
+
+ PerMessageDeflateExtension(
+ final boolean serverNoContextTakeover,
+ final boolean clientNoContextTakeover,
+ final Integer clientMaxWindowBits,
+ final Integer serverMaxWindowBits) {
+ this.serverNoContextTakeover = serverNoContextTakeover;
+ this.clientNoContextTakeover = clientNoContextTakeover;
+ this.clientMaxWindowBits = clientMaxWindowBits;
+ this.serverMaxWindowBits = serverMaxWindowBits;
+ this.decodingMessage = false;
+ }
+
+ @Override
+ public String getName() {
+ return "permessage-deflate";
+ }
+
+ @Override
+ public boolean usesRsv1() {
+ return true;
+ }
+
+ @Override
+ public ByteBuffer decode(final WebSocketFrameType type, final boolean fin, final ByteBuffer payload) throws WebSocketException {
+ if (!isDataFrame(type) && type != WebSocketFrameType.CONTINUATION) {
+ throw new WebSocketException("Unsupported frame type for permessage-deflate: " + type);
+ }
+ if (type == WebSocketFrameType.CONTINUATION && !decodingMessage) {
+ throw new WebSocketException("Unexpected continuation frame for permessage-deflate");
+ }
+ final byte[] input = toByteArray(payload);
+ final byte[] withTail;
+ if (fin) {
+ withTail = new byte[input.length + TAIL.length];
+ System.arraycopy(input, 0, withTail, 0, input.length);
+ System.arraycopy(TAIL, 0, withTail, input.length, TAIL.length);
+ } else {
+ withTail = input;
+ }
+ inflater.setInput(withTail);
+ final ByteArrayOutputStream out = new ByteArrayOutputStream(input.length);
+ final byte[] buffer = new byte[8192];
+ try {
+ while (!inflater.needsInput()) {
+ final int count = inflater.inflate(buffer);
+ if (count == 0 && inflater.needsInput()) {
+ break;
+ }
+ out.write(buffer, 0, count);
+ }
+ } catch (final Exception ex) {
+ throw new WebSocketException("Unable to inflate payload", ex);
+ }
+ if (fin) {
+ decodingMessage = false;
+ if (clientNoContextTakeover) {
+ inflater.reset();
+ }
+ } else {
+ decodingMessage = true;
+ }
+ return ByteBuffer.wrap(out.toByteArray());
+ }
+
+ @Override
+ public ByteBuffer encode(final WebSocketFrameType type, final boolean fin, final ByteBuffer payload) throws WebSocketException {
+ if (!isDataFrame(type) && type != WebSocketFrameType.CONTINUATION) {
+ throw new WebSocketException("Unsupported frame type for permessage-deflate: " + type);
+ }
+ final byte[] input = toByteArray(payload);
+ deflater.setInput(input);
+ final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, input.length / 2));
+ final byte[] buffer = new byte[8192];
+ while (!deflater.needsInput()) {
+ final int count = deflater.deflate(buffer, 0, buffer.length, Deflater.SYNC_FLUSH);
+ if (count > 0) {
+ out.write(buffer, 0, count);
+ } else {
+ break;
+ }
+ }
+ final byte[] data = out.toByteArray();
+ final ByteBuffer encoded;
+ if (data.length >= 4) {
+ encoded = ByteBuffer.wrap(data, 0, data.length - 4);
+ } else {
+ encoded = ByteBuffer.wrap(data);
+ }
+ if (fin && serverNoContextTakeover) {
+ deflater.reset();
+ }
+ return encoded;
+ }
+
+ @Override
+ public WebSocketExtensionData getResponseData() {
+ final Map params = new LinkedHashMap<>();
+ if (serverNoContextTakeover) {
+ params.put("server_no_context_takeover", null);
+ }
+ if (clientNoContextTakeover) {
+ params.put("client_no_context_takeover", null);
+ }
+ if (clientMaxWindowBits != null) {
+ params.put("client_max_window_bits", Integer.toString(clientMaxWindowBits));
+ }
+ if (serverMaxWindowBits != null) {
+ params.put("server_max_window_bits", Integer.toString(serverMaxWindowBits));
+ }
+ return new WebSocketExtensionData(getName(), params);
+ }
+
+ private static boolean isDataFrame(final WebSocketFrameType type) {
+ return type == WebSocketFrameType.TEXT || type == WebSocketFrameType.BINARY;
+ }
+
+ static boolean isValidWindowBits(final Integer bits) {
+ return bits == null || bits >= MIN_WINDOW_BITS && bits <= MAX_WINDOW_BITS;
+ }
+
+ private static byte[] toByteArray(final ByteBuffer payload) {
+ final ByteBuffer buffer = payload != null ? payload.asReadOnlyBuffer() : ByteBuffer.allocate(0);
+ final byte[] data = new byte[buffer.remaining()];
+ buffer.get(data);
+ return data;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactory.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactory.java
new file mode 100644
index 0000000000..6166efe842
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactory.java
@@ -0,0 +1,96 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+/**
+ * Factory for {@code permessage-deflate} extensions (RFC 7692).
+ *
+ * Note: the JDK {@link java.util.zip.Deflater} / {@link java.util.zip.Inflater}
+ * only support a 15-bit window size. This factory therefore accepts
+ * {@code client_max_window_bits} / {@code server_max_window_bits} only when
+ * they are either absent or explicitly set to {@code 15}. Other values are
+ * rejected during negotiation.
+ */
+public final class PerMessageDeflateExtensionFactory implements WebSocketExtensionFactory {
+
+ @Override
+ public String getName() {
+ return "permessage-deflate";
+ }
+
+ @Override
+ public WebSocketExtension create(final WebSocketExtensionData request, final boolean server) {
+ if (request == null) {
+ return null;
+ }
+ final String name = request.getName();
+ if (!"permessage-deflate".equals(name)) {
+ return null;
+ }
+ final boolean serverNoContextTakeover = request.getParameters().containsKey("server_no_context_takeover");
+ final boolean clientNoContextTakeover = request.getParameters().containsKey("client_no_context_takeover");
+ final boolean clientMaxBitsPresent = request.getParameters().containsKey("client_max_window_bits");
+ final boolean serverMaxBitsPresent = request.getParameters().containsKey("server_max_window_bits");
+ Integer clientMaxWindowBits = parseWindowBits(request.getParameters().get("client_max_window_bits"));
+ Integer serverMaxWindowBits = parseWindowBits(request.getParameters().get("server_max_window_bits"));
+ if (clientMaxBitsPresent && clientMaxWindowBits == null) {
+ clientMaxWindowBits = 15;
+ }
+ if (serverMaxBitsPresent && serverMaxWindowBits == null) {
+ serverMaxWindowBits = 15;
+ }
+
+ if (!PerMessageDeflateExtension.isValidWindowBits(clientMaxWindowBits)
+ || !PerMessageDeflateExtension.isValidWindowBits(serverMaxWindowBits)) {
+ return null;
+ }
+ // JDK Deflater/Inflater only supports 15-bit window size.
+ if (!isSupportedWindowBits(clientMaxWindowBits) || !isSupportedWindowBits(serverMaxWindowBits)) {
+ return null;
+ }
+ return new PerMessageDeflateExtension(
+ serverNoContextTakeover,
+ clientNoContextTakeover,
+ clientMaxWindowBits,
+ serverMaxWindowBits);
+ }
+
+ private static Integer parseWindowBits(final String value) {
+ if (value == null || value.isEmpty()) {
+ return null;
+ }
+ try {
+ return Integer.parseInt(value);
+ } catch (final NumberFormatException ignore) {
+ return null;
+ }
+ }
+
+ private static boolean isSupportedWindowBits(final Integer bits) {
+ return bits == null || bits == 15;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketCloseStatus.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketCloseStatus.java
new file mode 100644
index 0000000000..e1c5677de1
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketCloseStatus.java
@@ -0,0 +1,55 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+public enum WebSocketCloseStatus {
+
+ NORMAL(1000),
+ GOING_AWAY(1001),
+ PROTOCOL_ERROR(1002),
+ UNSUPPORTED_DATA(1003),
+ NO_STATUS_RECEIVED(1005),
+ ABNORMAL_CLOSURE(1006),
+ INVALID_PAYLOAD(1007),
+ POLICY_VIOLATION(1008),
+ MESSAGE_TOO_BIG(1009),
+ MANDATORY_EXTENSION(1010),
+ INTERNAL_ERROR(1011),
+ SERVICE_RESTART(1012),
+ TRY_AGAIN_LATER(1013),
+ BAD_GATEWAY(1014);
+
+ private final int code;
+
+ WebSocketCloseStatus(final int code) {
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConfig.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConfig.java
new file mode 100644
index 0000000000..589275e486
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConfig.java
@@ -0,0 +1,74 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket;
+
+import org.apache.hc.core5.util.Args;
+
+public final class WebSocketConfig {
+
+ public static final WebSocketConfig DEFAULT = WebSocketConfig.custom().build();
+
+ private final int maxFramePayloadSize;
+ private final int maxMessageSize;
+
+ private WebSocketConfig(final Builder builder) {
+ this.maxFramePayloadSize = builder.maxFramePayloadSize;
+ this.maxMessageSize = builder.maxMessageSize;
+ }
+
+ public int getMaxFramePayloadSize() {
+ return maxFramePayloadSize;
+ }
+
+ public int getMaxMessageSize() {
+ return maxMessageSize;
+ }
+
+ public static Builder custom() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+
+ private int maxFramePayloadSize = 16 * 1024 * 1024;
+ private int maxMessageSize = 64 * 1024 * 1024;
+
+ public Builder setMaxFramePayloadSize(final int maxFramePayloadSize) {
+ this.maxFramePayloadSize = Args.positive(maxFramePayloadSize, "Max frame payload size");
+ return this;
+ }
+
+ public Builder setMaxMessageSize(final int maxMessageSize) {
+ this.maxMessageSize = Args.positive(maxMessageSize, "Max message size");
+ return this;
+ }
+
+ public WebSocketConfig build() {
+ return new WebSocketConfig(this);
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConstants.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConstants.java
new file mode 100644
index 0000000000..34ae7cb4b3
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketConstants.java
@@ -0,0 +1,47 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+public final class WebSocketConstants {
+
+ public static final String WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
+ public static final String SEC_WEBSOCKET_KEY = "Sec-WebSocket-Key";
+ public static final String SEC_WEBSOCKET_VERSION = "Sec-WebSocket-Version";
+ public static final String SEC_WEBSOCKET_ACCEPT = "Sec-WebSocket-Accept";
+ public static final String SEC_WEBSOCKET_PROTOCOL = "Sec-WebSocket-Protocol";
+ public static final String SEC_WEBSOCKET_EXTENSIONS = "Sec-WebSocket-Extensions";
+ public static final String SEC_WEBSOCKET_VERSION_LOWER = "sec-websocket-version";
+ public static final String SEC_WEBSOCKET_PROTOCOL_LOWER = "sec-websocket-protocol";
+ public static final String SEC_WEBSOCKET_EXTENSIONS_LOWER = "sec-websocket-extensions";
+ /**
+ * RFC 8441 extended CONNECT pseudo-header.
+ */
+ public static final String PSEUDO_PROTOCOL = ":protocol";
+
+ private WebSocketConstants() {
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketException.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketException.java
new file mode 100644
index 0000000000..4641e29f41
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketException.java
@@ -0,0 +1,42 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import java.io.IOException;
+
+public class WebSocketException extends IOException {
+
+ private static final long serialVersionUID = 1L;
+
+ public WebSocketException(final String message) {
+ super(message);
+ }
+
+ public WebSocketException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtension.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtension.java
new file mode 100644
index 0000000000..73988651cd
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtension.java
@@ -0,0 +1,64 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket;
+
+import java.nio.ByteBuffer;
+
+public interface WebSocketExtension {
+
+ String getName();
+
+ default boolean usesRsv1() {
+ return false;
+ }
+
+ default boolean usesRsv2() {
+ return false;
+ }
+
+ default boolean usesRsv3() {
+ return false;
+ }
+
+ default ByteBuffer decode(
+ final WebSocketFrameType type,
+ final boolean fin,
+ final ByteBuffer payload) throws WebSocketException {
+ return payload;
+ }
+
+ default ByteBuffer encode(
+ final WebSocketFrameType type,
+ final boolean fin,
+ final ByteBuffer payload) throws WebSocketException {
+ return payload;
+ }
+
+ default WebSocketExtensionData getResponseData() {
+ return new WebSocketExtensionData(getName(), null);
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionData.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionData.java
new file mode 100644
index 0000000000..082fea84dd
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionData.java
@@ -0,0 +1,68 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.TextUtils;
+
+public final class WebSocketExtensionData {
+
+ private final String name;
+ private final Map parameters;
+
+ public WebSocketExtensionData(final String name, final Map parameters) {
+ this.name = Args.notBlank(name, "Extension name");
+ if (parameters != null && !parameters.isEmpty()) {
+ this.parameters = new LinkedHashMap<>(parameters);
+ } else {
+ this.parameters = Collections.emptyMap();
+ }
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Map getParameters() {
+ return parameters;
+ }
+
+ public String format() {
+ final StringBuilder buffer = new StringBuilder(name);
+ for (final Map.Entry entry : parameters.entrySet()) {
+ buffer.append("; ").append(entry.getKey());
+ if (!TextUtils.isBlank(entry.getValue())) {
+ buffer.append('=').append(entry.getValue());
+ }
+ }
+ return buffer.toString();
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionFactory.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionFactory.java
new file mode 100644
index 0000000000..ebbb1712aa
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionFactory.java
@@ -0,0 +1,34 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+public interface WebSocketExtensionFactory {
+
+ String getName();
+
+ WebSocketExtension create(WebSocketExtensionData request, boolean server) throws WebSocketException;
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiation.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiation.java
new file mode 100644
index 0000000000..4acf9779ce
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiation.java
@@ -0,0 +1,61 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.StringJoiner;
+
+
+public final class WebSocketExtensionNegotiation {
+
+ private final List extensions;
+ private final List responseData;
+
+ WebSocketExtensionNegotiation(
+ final List extensions,
+ final List responseData) {
+ this.extensions = extensions != null ? extensions : Collections.emptyList();
+ this.responseData = responseData != null ? responseData : Collections.emptyList();
+ }
+
+ public List getExtensions() {
+ return extensions;
+ }
+
+ public List getResponseData() {
+ return responseData;
+ }
+
+ public String formatResponseHeader() {
+ final StringJoiner joiner = new StringJoiner(", ");
+ for (final WebSocketExtensionData data : responseData) {
+ joiner.add(data.format());
+ }
+ return joiner.length() > 0 ? joiner.toString() : null;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistry.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistry.java
new file mode 100644
index 0000000000..12fa259f3f
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistry.java
@@ -0,0 +1,74 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.hc.core5.util.Args;
+
+public final class WebSocketExtensionRegistry {
+
+ private final Map factories;
+
+ public WebSocketExtensionRegistry() {
+ this.factories = new LinkedHashMap<>();
+ }
+
+ public WebSocketExtensionRegistry register(final WebSocketExtensionFactory factory) {
+ Args.notNull(factory, "Extension factory");
+ factories.put(factory.getName(), factory);
+ return this;
+ }
+
+ public WebSocketExtensionNegotiation negotiate(
+ final List requested,
+ final boolean server) throws WebSocketException {
+ final List extensions = new ArrayList<>();
+ final List responseData = new ArrayList<>();
+ if (requested != null) {
+ for (final WebSocketExtensionData request : requested) {
+ final WebSocketExtensionFactory factory = factories.get(request.getName());
+ if (factory != null) {
+ final WebSocketExtension extension = factory.create(request, server);
+ if (extension != null) {
+ extensions.add(extension);
+ responseData.add(extension.getResponseData());
+ }
+ }
+ }
+ }
+ return new WebSocketExtensionNegotiation(extensions, responseData);
+ }
+
+ public static WebSocketExtensionRegistry createDefault() {
+ return new WebSocketExtensionRegistry()
+ .register(new PerMessageDeflateExtensionFactory());
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensions.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensions.java
new file mode 100644
index 0000000000..6c853b50fb
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketExtensions.java
@@ -0,0 +1,63 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HeaderElement;
+import org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.http.message.MessageSupport;
+import org.apache.hc.core5.util.TextUtils;
+
+public final class WebSocketExtensions {
+
+ private WebSocketExtensions() {
+ }
+
+ public static List parse(final Header header) {
+ final List extensions = new ArrayList<>();
+ if (header == null) {
+ return extensions;
+ }
+ for (final HeaderElement element : MessageSupport.parseElements(header)) {
+ final String name = element.getName();
+ if (TextUtils.isBlank(name)) {
+ continue;
+ }
+ final Map params = new LinkedHashMap<>();
+ for (final NameValuePair param : element.getParameters()) {
+ params.put(param.getName(), param.getValue());
+ }
+ extensions.add(new WebSocketExtensionData(name, params));
+ }
+ return extensions;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrame.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrame.java
new file mode 100644
index 0000000000..1460628f39
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrame.java
@@ -0,0 +1,80 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket;
+
+import java.nio.ByteBuffer;
+
+import org.apache.hc.core5.util.Args;
+
+public final class WebSocketFrame {
+
+ private final boolean fin;
+ private final boolean rsv1;
+ private final boolean rsv2;
+ private final boolean rsv3;
+ private final WebSocketFrameType type;
+ private final ByteBuffer payload;
+
+ public WebSocketFrame(
+ final boolean fin,
+ final boolean rsv1,
+ final boolean rsv2,
+ final boolean rsv3,
+ final WebSocketFrameType type,
+ final ByteBuffer payload) {
+ this.fin = fin;
+ this.rsv1 = rsv1;
+ this.rsv2 = rsv2;
+ this.rsv3 = rsv3;
+ this.type = Args.notNull(type, "Frame type");
+ this.payload = payload != null ? payload.asReadOnlyBuffer() : ByteBuffer.allocate(0).asReadOnlyBuffer();
+ }
+
+ public boolean isFin() {
+ return fin;
+ }
+
+ public boolean isRsv1() {
+ return rsv1;
+ }
+
+ public boolean isRsv2() {
+ return rsv2;
+ }
+
+ public boolean isRsv3() {
+ return rsv3;
+ }
+
+ public WebSocketFrameType getType() {
+ return type;
+ }
+
+ public ByteBuffer getPayload() {
+ return payload.asReadOnlyBuffer();
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameReader.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameReader.java
new file mode 100644
index 0000000000..af14ff2518
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameReader.java
@@ -0,0 +1,166 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Predicate;
+
+import org.apache.hc.core5.util.Args;
+
+class WebSocketFrameReader {
+
+ private final WebSocketConfig config;
+ private final InputStream inputStream;
+ private final List extensions;
+ private final WebSocketExtension rsv1Extension;
+ private final WebSocketExtension rsv2Extension;
+ private final WebSocketExtension rsv3Extension;
+ private boolean continuationCompressed;
+
+ WebSocketFrameReader(final WebSocketConfig config, final InputStream inputStream, final List extensions) {
+ this.config = Args.notNull(config, "WebSocket config");
+ this.inputStream = Args.notNull(inputStream, "Input stream");
+ this.extensions = extensions != null ? extensions : Collections.emptyList();
+ this.rsv1Extension = selectExtension(WebSocketExtension::usesRsv1);
+ this.rsv2Extension = selectExtension(WebSocketExtension::usesRsv2);
+ this.rsv3Extension = selectExtension(WebSocketExtension::usesRsv3);
+ this.continuationCompressed = false;
+ }
+
+ WebSocketFrame readFrame() throws IOException {
+ final int b1 = inputStream.read();
+ if (b1 == -1) {
+ return null;
+ }
+ final int b2 = readByte();
+ final boolean fin = (b1 & 0x80) != 0;
+ final boolean rsv1 = (b1 & 0x40) != 0;
+ final boolean rsv2 = (b1 & 0x20) != 0;
+ final boolean rsv3 = (b1 & 0x10) != 0;
+ final int opcode = b1 & 0x0F;
+ final WebSocketFrameType type = WebSocketFrameType.fromOpcode(opcode);
+ if (type == null) {
+ throw new WebSocketException("Unsupported opcode: " + opcode);
+ }
+ if (type.isControl() && (rsv1 || rsv2 || rsv3)) {
+ throw new WebSocketException("Invalid RSV bits for control frame");
+ }
+ if (type == WebSocketFrameType.CONTINUATION && rsv1) {
+ throw new WebSocketException("RSV1 must be 0 on continuation frames");
+ }
+ if (rsv1 && rsv1Extension == null) {
+ throw new WebSocketException("Unexpected RSV1 bit");
+ }
+ if (rsv2 && rsv2Extension == null) {
+ throw new WebSocketException("Unexpected RSV2 bit");
+ }
+ if (rsv3 && rsv3Extension == null) {
+ throw new WebSocketException("Unexpected RSV3 bit");
+ }
+ final boolean masked = (b2 & 0x80) != 0;
+ if (!masked) {
+ throw new WebSocketException("Client frame is not masked");
+ }
+ long len = b2 & 0x7F;
+ if (len == 126) {
+ len = ((readByte() & 0xFF) << 8) | (readByte() & 0xFF);
+ } else if (len == 127) {
+ len = 0;
+ for (int i = 0; i < 8; i++) {
+ len = (len << 8) | (readByte() & 0xFF);
+ }
+ }
+ if (len > Integer.MAX_VALUE) {
+ throw new WebSocketException("Frame payload too large: " + len);
+ }
+ if (len > config.getMaxFramePayloadSize()) {
+ throw new WebSocketException("Frame payload exceeds limit: " + len);
+ }
+ final byte[] maskKey = new byte[4];
+ readFully(maskKey);
+ final byte[] payload = new byte[(int) len];
+ readFully(payload);
+ for (int i = 0; i < payload.length; i++) {
+ payload[i] = (byte) (payload[i] ^ maskKey[i % 4]);
+ }
+ ByteBuffer data = ByteBuffer.wrap(payload);
+ if (rsv1 && rsv1Extension != null) {
+ data = rsv1Extension.decode(type, fin, data);
+ continuationCompressed = !fin && (type == WebSocketFrameType.TEXT || type == WebSocketFrameType.BINARY);
+ } else if (type == WebSocketFrameType.CONTINUATION && continuationCompressed && rsv1Extension != null) {
+ data = rsv1Extension.decode(type, fin, data);
+ if (fin) {
+ continuationCompressed = false;
+ }
+ } else if (type == WebSocketFrameType.CONTINUATION && fin) {
+ continuationCompressed = false;
+ }
+ if (rsv2 && rsv2Extension != null) {
+ data = rsv2Extension.decode(type, fin, data);
+ }
+ if (rsv3 && rsv3Extension != null) {
+ data = rsv3Extension.decode(type, fin, data);
+ }
+ return new WebSocketFrame(fin, false, false, false, type, data);
+ }
+
+ private WebSocketExtension selectExtension(final Predicate predicate) {
+ WebSocketExtension selected = null;
+ for (final WebSocketExtension extension : extensions) {
+ if (predicate.test(extension)) {
+ if (selected != null) {
+ throw new IllegalStateException("Multiple extensions use the same RSV bit");
+ }
+ selected = extension;
+ }
+ }
+ return selected;
+ }
+
+ private int readByte() throws IOException {
+ final int b = inputStream.read();
+ if (b == -1) {
+ throw new IOException("Unexpected end of stream");
+ }
+ return b;
+ }
+
+ private void readFully(final byte[] buffer) throws IOException {
+ int off = 0;
+ while (off < buffer.length) {
+ final int read = inputStream.read(buffer, off, buffer.length - off);
+ if (read == -1) {
+ throw new IOException("Unexpected end of stream");
+ }
+ off += read;
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameType.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameType.java
new file mode 100644
index 0000000000..e57dd8908c
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameType.java
@@ -0,0 +1,60 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+public enum WebSocketFrameType {
+
+ CONTINUATION(0x0),
+ TEXT(0x1),
+ BINARY(0x2),
+ CLOSE(0x8),
+ PING(0x9),
+ PONG(0xA);
+
+ private final int opcode;
+
+ WebSocketFrameType(final int opcode) {
+ this.opcode = opcode;
+ }
+
+ public int getOpcode() {
+ return opcode;
+ }
+
+ public boolean isControl() {
+ return opcode >= 0x8;
+ }
+
+ public static WebSocketFrameType fromOpcode(final int opcode) {
+ for (final WebSocketFrameType type : values()) {
+ if (type.opcode == opcode) {
+ return type;
+ }
+ }
+ return null;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameWriter.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameWriter.java
new file mode 100644
index 0000000000..69ef31e5b8
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketFrameWriter.java
@@ -0,0 +1,135 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.hc.core5.util.Args;
+
+class WebSocketFrameWriter {
+
+ private final OutputStream outputStream;
+ private final List extensions;
+
+ WebSocketFrameWriter(final OutputStream outputStream, final List extensions) {
+ this.outputStream = Args.notNull(outputStream, "Output stream");
+ this.extensions = extensions != null ? extensions : Collections.emptyList();
+ }
+
+ void writeText(final String text) throws IOException, WebSocketException {
+ Args.notNull(text, "Text");
+ writeDataFrame(WebSocketFrameType.TEXT, ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ void writeBinary(final ByteBuffer payload) throws IOException, WebSocketException {
+ writeDataFrame(WebSocketFrameType.BINARY, payload);
+ }
+
+ void writePing(final ByteBuffer payload) throws IOException {
+ writeFrame(WebSocketFrameType.PING, payload, false, false, false);
+ }
+
+ void writePong(final ByteBuffer payload) throws IOException {
+ writeFrame(WebSocketFrameType.PONG, payload, false, false, false);
+ }
+
+ void writeClose(final int statusCode, final String reason) throws IOException {
+ final byte[] reasonBytes = reason != null ? reason.getBytes(StandardCharsets.UTF_8) : new byte[0];
+ final int len = 2 + reasonBytes.length;
+ final ByteBuffer buffer = ByteBuffer.allocate(len);
+ buffer.put((byte) ((statusCode >> 8) & 0xFF));
+ buffer.put((byte) (statusCode & 0xFF));
+ buffer.put(reasonBytes);
+ buffer.flip();
+ writeFrame(WebSocketFrameType.CLOSE, buffer, false, false, false);
+ }
+
+ private void writeDataFrame(final WebSocketFrameType type, final ByteBuffer payload) throws IOException, WebSocketException {
+ ByteBuffer data = payload != null ? payload.asReadOnlyBuffer() : ByteBuffer.allocate(0);
+ boolean rsv1 = false;
+ boolean rsv2 = false;
+ boolean rsv3 = false;
+ for (final WebSocketExtension extension : extensions) {
+ if (extension.usesRsv1()) {
+ rsv1 = true;
+ }
+ if (extension.usesRsv2()) {
+ rsv2 = true;
+ }
+ if (extension.usesRsv3()) {
+ rsv3 = true;
+ }
+ data = extension.encode(type, true, data);
+ }
+ writeFrame(type, data, rsv1, rsv2, rsv3);
+ }
+
+ private void writeFrame(
+ final WebSocketFrameType type,
+ final ByteBuffer payload,
+ final boolean rsv1,
+ final boolean rsv2,
+ final boolean rsv3) throws IOException {
+ Args.notNull(type, "Frame type");
+ final ByteBuffer buffer = payload != null ? payload.asReadOnlyBuffer() : ByteBuffer.allocate(0);
+ final int payloadLen = buffer.remaining();
+ int firstByte = 0x80 | (type.getOpcode() & 0x0F);
+ if (rsv1) {
+ firstByte |= 0x40;
+ }
+ if (rsv2) {
+ firstByte |= 0x20;
+ }
+ if (rsv3) {
+ firstByte |= 0x10;
+ }
+ outputStream.write(firstByte);
+ if (payloadLen <= 125) {
+ outputStream.write(payloadLen);
+ } else if (payloadLen <= 0xFFFF) {
+ outputStream.write(126);
+ outputStream.write((payloadLen >> 8) & 0xFF);
+ outputStream.write(payloadLen & 0xFF);
+ } else {
+ outputStream.write(127);
+ for (int i = 7; i >= 0; i--) {
+ outputStream.write((int) (((long) payloadLen >> (i * 8)) & 0xFF));
+ }
+ }
+ if (payloadLen > 0) {
+ final byte[] data = new byte[payloadLen];
+ buffer.get(data);
+ outputStream.write(data);
+ }
+ outputStream.flush();
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandler.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandler.java
new file mode 100644
index 0000000000..1383dbf4a1
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandler.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.core5.websocket;
+
+import java.nio.ByteBuffer;
+import java.util.List;
+
+public interface WebSocketHandler {
+
+ default String selectSubprotocol(final List offered) {
+ return null;
+ }
+
+ default void onOpen(final WebSocketSession session) {
+ }
+
+ default void onText(final WebSocketSession session, final String text) throws WebSocketException {
+ }
+
+ default void onBinary(final WebSocketSession session, final ByteBuffer data) throws WebSocketException {
+ }
+
+ default void onPing(final WebSocketSession session, final ByteBuffer data) throws WebSocketException {
+ }
+
+ default void onPong(final WebSocketSession session, final ByteBuffer data) throws WebSocketException {
+ }
+
+ default void onClose(final WebSocketSession session, final int statusCode, final String reason) {
+ }
+
+ default void onError(final WebSocketSession session, final Exception cause) {
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandshake.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandshake.java
new file mode 100644
index 0000000000..e2dac5f982
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketHandshake.java
@@ -0,0 +1,107 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.Method;
+import org.apache.hc.core5.http.message.MessageSupport;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.TextUtils;
+
+public final class WebSocketHandshake {
+
+ private static final String SUPPORTED_VERSION = "13";
+
+ private WebSocketHandshake() {
+ }
+
+ public static boolean isWebSocketUpgrade(final HttpRequest request) {
+ if (request == null || request.getMethod() == null) {
+ return false;
+ }
+ if (!Method.GET.isSame(request.getMethod())) {
+ return false;
+ }
+ if (!containsToken(request, HttpHeaders.CONNECTION, "Upgrade")) {
+ return false;
+ }
+ final Header upgradeHeader = request.getFirstHeader(HttpHeaders.UPGRADE);
+ if (upgradeHeader == null || !"websocket".equalsIgnoreCase(upgradeHeader.getValue())) {
+ return false;
+ }
+ final Header versionHeader = request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_VERSION);
+ if (versionHeader == null || !SUPPORTED_VERSION.equals(versionHeader.getValue())) {
+ return false;
+ }
+ final Header keyHeader = request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_KEY);
+ return keyHeader != null && !TextUtils.isBlank(keyHeader.getValue());
+ }
+
+ public static String createAcceptKey(final String key) throws WebSocketException {
+ try {
+ Args.notBlank(key, "WebSocket key");
+ final MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
+ final String acceptSource = key.trim() + WebSocketConstants.WEBSOCKET_GUID;
+ final byte[] digest = sha1.digest(acceptSource.getBytes(StandardCharsets.ISO_8859_1));
+ return Base64.getEncoder().encodeToString(digest);
+ } catch (final Exception ex) {
+ throw new WebSocketException("Unable to compute Sec-WebSocket-Accept", ex);
+ }
+ }
+
+ public static List parseSubprotocols(final Header header) {
+ final List protocols = new ArrayList<>();
+ if (header == null) {
+ return protocols;
+ }
+ for (final String token : MessageSupport.parseTokens(header)) {
+ if (!TextUtils.isBlank(token)) {
+ protocols.add(token);
+ }
+ }
+ return protocols;
+ }
+
+ private static boolean containsToken(final HttpRequest request, final String headerName, final String token) {
+ for (final Header hdr : request.getHeaders(headerName)) {
+ for (final String value : MessageSupport.parseTokens(hdr)) {
+ if (token.equalsIgnoreCase(value)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketSession.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketSession.java
new file mode 100644
index 0000000000..4fe9e78f60
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/WebSocketSession.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.core5.websocket;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.SocketAddress;
+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.Collections;
+import java.util.List;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException;
+
+public final class WebSocketSession {
+
+ private final WebSocketConfig config;
+ private final InputStream inputStream;
+ private final OutputStream outputStream;
+ private final SocketAddress remoteAddress;
+ private final SocketAddress localAddress;
+ private final WebSocketFrameReader reader;
+ private final WebSocketFrameWriter writer;
+ private final ReentrantLock writeLock = new ReentrantLock();
+ private volatile boolean closeSent;
+
+ public WebSocketSession(
+ final WebSocketConfig config,
+ final InputStream inputStream,
+ final OutputStream outputStream,
+ final SocketAddress remoteAddress,
+ final SocketAddress localAddress,
+ final List extensions) {
+ this.config = config != null ? config : WebSocketConfig.DEFAULT;
+ this.inputStream = Args.notNull(inputStream, "Input stream");
+ this.outputStream = Args.notNull(outputStream, "Output stream");
+ this.remoteAddress = remoteAddress;
+ this.localAddress = localAddress;
+ final List negotiated = extensions != null ? extensions : Collections.emptyList();
+ this.reader = new WebSocketFrameReader(this.config, this.inputStream, negotiated);
+ this.writer = new WebSocketFrameWriter(this.outputStream, negotiated);
+ this.closeSent = false;
+ }
+
+ public SocketAddress getRemoteAddress() {
+ return remoteAddress;
+ }
+
+ public SocketAddress getLocalAddress() {
+ return localAddress;
+ }
+
+ public WebSocketFrame readFrame() throws IOException {
+ return reader.readFrame();
+ }
+
+ public void sendText(final String text) throws IOException, WebSocketException {
+ Args.notNull(text, "Text");
+ writeLock.lock();
+ try {
+ writer.writeText(text);
+ } finally {
+ writeLock.unlock();
+ }
+ }
+
+ public void sendBinary(final ByteBuffer data) throws IOException, WebSocketException {
+ Args.notNull(data, "Binary payload");
+ writeLock.lock();
+ try {
+ writer.writeBinary(data);
+ } finally {
+ writeLock.unlock();
+ }
+ }
+
+ public void sendPing(final ByteBuffer data) throws IOException {
+ writeLock.lock();
+ try {
+ writer.writePing(data);
+ } finally {
+ writeLock.unlock();
+ }
+ }
+
+ public void sendPong(final ByteBuffer data) throws IOException {
+ writeLock.lock();
+ try {
+ writer.writePong(data);
+ } finally {
+ writeLock.unlock();
+ }
+ }
+
+ public void close(final int statusCode, final String reason) throws IOException {
+ writeLock.lock();
+ try {
+ if (!closeSent) {
+ writer.writeClose(statusCode, reason);
+ closeSent = true;
+ }
+ } finally {
+ writeLock.unlock();
+ }
+ }
+
+ public void close(final WebSocketCloseStatus status) throws IOException {
+ final int code = status != null ? status.getCode() : WebSocketCloseStatus.NORMAL.getCode();
+ close(code, "");
+ }
+
+ public void closeQuietly() {
+ try {
+ close(WebSocketCloseStatus.NORMAL.getCode(), "");
+ } catch (final IOException ignore) {
+ // ignore
+ }
+ }
+
+ public static String decodeText(final ByteBuffer payload) throws WebSocketException {
+ try {
+ final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT);
+ final CharBuffer chars = decoder.decode(payload.asReadOnlyBuffer());
+ return chars.toString();
+ } catch (final CharacterCodingException ex) {
+ throw new WebSocketProtocolException(1007, "Invalid UTF-8 payload");
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolException.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolException.java
new file mode 100644
index 0000000000..85d708d9d9
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolException.java
@@ -0,0 +1,41 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.exceptions;
+
+
+import org.apache.hc.core5.annotation.Internal;
+
+@Internal
+public final class WebSocketProtocolException extends RuntimeException {
+
+ public final int closeCode;
+
+ public WebSocketProtocolException(final int closeCode, final String message) {
+ super(message);
+ this.closeCode = closeCode;
+ }
+}
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/package-info.java
new file mode 100644
index 0000000000..d68edcf812
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/exceptions/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * WebSocket-specific exception types.
+ *
+ * Primarily used to signal protocol-level errors with associated
+ * close codes.
+ *
+ * @since 5.7
+ */
+package org.apache.hc.core5.websocket.exceptions;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/ExtensionChain.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/ExtensionChain.java
new file mode 100644
index 0000000000..03190a9eb4
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/ExtensionChain.java
@@ -0,0 +1,135 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.extension;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * Simple single-step chain; if multiple extensions are added they are applied in order.
+ * Only the FIRST extension can contribute the RSV bit (RSV1 in practice).
+ */
+@Internal
+public final class ExtensionChain {
+ private final List exts = new ArrayList<>();
+
+ public void add(final WebSocketExtensionChain e) {
+ if (e != null) {
+ exts.add(e);
+ }
+ }
+
+ public boolean isEmpty() {
+ return exts.isEmpty();
+ }
+
+ /**
+ * RSV bits used by the first extension in the chain (if any).
+ * Only the first extension may contribute RSV bits.
+ */
+ public int rsvMask() {
+ if (exts.isEmpty()) {
+ return 0;
+ }
+ return exts.get(0).rsvMask();
+ }
+
+ /**
+ * App-thread encoder chain.
+ */
+ public EncodeChain newEncodeChain() {
+ final List encs = new ArrayList<>(exts.size());
+ for (final WebSocketExtensionChain e : exts) {
+ encs.add(e.newEncoder());
+ }
+ return new EncodeChain(encs);
+ }
+
+ /**
+ * I/O-thread decoder chain.
+ */
+ public DecodeChain newDecodeChain() {
+ final List decs = new ArrayList<>(exts.size());
+ for (final WebSocketExtensionChain e : exts) {
+ decs.add(e.newDecoder());
+ }
+ return new DecodeChain(decs);
+ }
+
+ // ----------------------
+
+ public static final class EncodeChain {
+ private final List encs;
+
+ public EncodeChain(final List encs) {
+ this.encs = encs;
+ }
+
+ /**
+ * Encode one fragment through the chain; note RSV flag for the first extension.
+ * Returns {@link WebSocketExtensionChain.Encoded}.
+ */
+ public WebSocketExtensionChain.Encoded encode(final byte[] data, final boolean first, final boolean fin) {
+ if (encs.isEmpty()) {
+ return new WebSocketExtensionChain.Encoded(data, false);
+ }
+ byte[] out = data;
+ boolean setRsv1 = false;
+ boolean firstExt = true;
+ for (final WebSocketExtensionChain.Encoder e : encs) {
+ final WebSocketExtensionChain.Encoded res = e.encode(out, first, fin);
+ out = res.payload;
+ if (first && firstExt && res.setRsvOnFirst) {
+ setRsv1 = true;
+ }
+ firstExt = false;
+ }
+ return new WebSocketExtensionChain.Encoded(out, setRsv1);
+ }
+ }
+
+ public static final class DecodeChain {
+ private final List decs;
+
+ public DecodeChain(final List decs) {
+ this.decs = decs;
+ }
+
+ /**
+ * Decode a full message (reverse order if stacking).
+ */
+ public byte[] decode(final byte[] data) throws Exception {
+ byte[] out = data;
+ for (int i = decs.size() - 1; i >= 0; i--) {
+ out = decs.get(i).decode(out);
+ }
+ return out;
+ }
+ }
+}
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/PerMessageDeflate.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/PerMessageDeflate.java
new file mode 100644
index 0000000000..a8ee077433
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/PerMessageDeflate.java
@@ -0,0 +1,195 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.extension;
+
+import java.io.ByteArrayOutputStream;
+import java.util.zip.Deflater;
+import java.util.zip.Inflater;
+
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.websocket.frame.FrameHeaderBits;
+
+/**
+ * permessage-deflate (RFC 7692).
+ *
+ * Window bit parameters are negotiated during the handshake:
+ * {@code client_max_window_bits} limits the client's compression window (client->server),
+ * while {@code server_max_window_bits} limits the server's compression window (server->client).
+ * The decoder can accept any server window size (8..15). The encoder currently requires
+ * {@code client_max_window_bits} to be 15, due to JDK Deflater limitations.
+ */
+@Internal
+public final class PerMessageDeflate implements WebSocketExtensionChain {
+ private static final byte[] TAIL = new byte[]{0x00, 0x00, (byte) 0xFF, (byte) 0xFF};
+
+ private final boolean enabled;
+ private final boolean serverNoContextTakeover;
+ private final boolean clientNoContextTakeover;
+ private final Integer clientMaxWindowBits; // negotiated or null
+ private final Integer serverMaxWindowBits; // negotiated or null
+
+ public PerMessageDeflate(final boolean enabled,
+ final boolean serverNoContextTakeover,
+ final boolean clientNoContextTakeover,
+ final Integer clientMaxWindowBits,
+ final Integer serverMaxWindowBits) {
+ this.enabled = enabled;
+ this.serverNoContextTakeover = serverNoContextTakeover;
+ this.clientNoContextTakeover = clientNoContextTakeover;
+ this.clientMaxWindowBits = clientMaxWindowBits;
+ this.serverMaxWindowBits = serverMaxWindowBits;
+ }
+
+ @Override
+ public int rsvMask() {
+ return FrameHeaderBits.RSV1;
+ }
+
+ @Override
+ public Encoder newEncoder() {
+ if (!enabled) {
+ return (data, first, fin) -> new Encoded(data, false);
+ }
+ return new Encoder() {
+ private final Deflater def = new Deflater(Deflater.DEFAULT_COMPRESSION, true); // raw DEFLATE
+
+ @Override
+ public Encoded encode(final byte[] data, final boolean first, final boolean fin) {
+ final byte[] out = first && fin
+ ? compressMessage(data)
+ : compressFragment(data, fin);
+ // RSV1 on first compressed data frame only
+ return new Encoded(out, first);
+ }
+
+ private byte[] compressMessage(final byte[] data) {
+ return doDeflate(data, true, true, clientNoContextTakeover);
+ }
+
+ private byte[] compressFragment(final byte[] data, final boolean fin) {
+ return doDeflate(data, fin, true, fin && clientNoContextTakeover);
+ }
+
+ private byte[] doDeflate(final byte[] data,
+ final boolean fin,
+ final boolean stripTail,
+ final boolean maybeReset) {
+ if (data == null || data.length == 0) {
+ if (fin && maybeReset) {
+ def.reset();
+ }
+ return new byte[0];
+ }
+ def.setInput(data);
+ final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, data.length / 2));
+ final byte[] buf = new byte[8192];
+ while (!def.needsInput()) {
+ final int n = def.deflate(buf, 0, buf.length, Deflater.SYNC_FLUSH);
+ if (n > 0) {
+ out.write(buf, 0, n);
+ } else {
+ break;
+ }
+ }
+ byte[] all = out.toByteArray();
+ if (stripTail && all.length >= 4) {
+ final int newLen = all.length - 4; // strip 00 00 FF FF
+ if (newLen <= 0) {
+ all = new byte[0];
+ } else {
+ final byte[] trimmed = new byte[newLen];
+ System.arraycopy(all, 0, trimmed, 0, newLen);
+ all = trimmed;
+ }
+ }
+ if (fin && maybeReset) {
+ def.reset();
+ }
+ return all;
+ }
+ };
+ }
+
+ @Override
+ public Decoder newDecoder() {
+ if (!enabled) {
+ return payload -> payload;
+ }
+ return new Decoder() {
+ private final Inflater inf = new Inflater(true);
+
+ @Override
+ public byte[] decode(final byte[] compressedMessage) throws Exception {
+ final byte[] withTail;
+ if (compressedMessage == null || compressedMessage.length == 0) {
+ withTail = TAIL.clone();
+ } else {
+ withTail = new byte[compressedMessage.length + 4];
+ System.arraycopy(compressedMessage, 0, withTail, 0, compressedMessage.length);
+ System.arraycopy(TAIL, 0, withTail, compressedMessage.length, 4);
+ }
+
+ inf.setInput(withTail);
+ final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, withTail.length * 2));
+ final byte[] buf = new byte[8192];
+ while (!inf.needsInput()) {
+ final int n = inf.inflate(buf);
+ if (n > 0) {
+ out.write(buf, 0, n);
+ } else {
+ break;
+ }
+ }
+ if (serverNoContextTakeover) {
+ inf.reset();
+ }
+ return out.toByteArray();
+ }
+ };
+ }
+
+ // optional getters for logging/tests
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public boolean isServerNoContextTakeover() {
+ return serverNoContextTakeover;
+ }
+
+ public boolean isClientNoContextTakeover() {
+ return clientNoContextTakeover;
+ }
+
+ public Integer getClientMaxWindowBits() {
+ return clientMaxWindowBits;
+ }
+
+ public Integer getServerMaxWindowBits() {
+ return serverMaxWindowBits;
+ }
+}
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/WebSocketExtensionChain.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/WebSocketExtensionChain.java
new file mode 100644
index 0000000000..006ee8eeab
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/WebSocketExtensionChain.java
@@ -0,0 +1,80 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.extension;
+
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * Generic extension hook for payload transform (e.g., permessage-deflate).
+ * Implementations may return RSV mask (usually RSV1) and indicate whether
+ * the first frame of a message should set RSV.
+ */
+@Internal
+public interface WebSocketExtensionChain {
+
+ /**
+ * RSV bits this extension uses on the first data frame (e.g. 0x40 for RSV1).
+ */
+ int rsvMask();
+
+ /**
+ * Create a thread-confined encoder instance (app thread).
+ */
+ Encoder newEncoder();
+
+ /**
+ * Create a thread-confined decoder instance (I/O thread).
+ */
+ Decoder newDecoder();
+
+ /**
+ * Encoded fragment result.
+ */
+ final class Encoded {
+ public final byte[] payload;
+ public final boolean setRsvOnFirst;
+
+ public Encoded(final byte[] payload, final boolean setRsvOnFirst) {
+ this.payload = payload;
+ this.setRsvOnFirst = setRsvOnFirst;
+ }
+ }
+
+ interface Encoder {
+ /**
+ * Encode one fragment; return transformed payload and whether to set RSV on FIRST frame.
+ */
+ Encoded encode(byte[] data, boolean first, boolean fin);
+ }
+
+ interface Decoder {
+ /**
+ * Decode a full message produced with this extension.
+ */
+ byte[] decode(byte[] payload) throws Exception;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/package-info.java
new file mode 100644
index 0000000000..ab6f4cae5a
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/extension/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * WebSocket extension SPI and implementations.
+ *
+ * Includes the generic {@code Extension} SPI, chaining support, and a
+ * permessage-deflate (RFC 7692) implementation.
+ *
+ * @since 5.7
+ */
+package org.apache.hc.core5.websocket.extension;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameHeaderBits.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameHeaderBits.java
new file mode 100644
index 0000000000..7b72942d48
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameHeaderBits.java
@@ -0,0 +1,49 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.frame;
+
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * WebSocket frame header bit masks (RFC 6455 §5.2).
+ */
+@Internal
+public final class FrameHeaderBits {
+ private FrameHeaderBits() {
+ }
+
+ // First header byte
+ public static final int FIN = 0x80;
+ public static final int RSV1 = 0x40;
+ public static final int RSV2 = 0x20;
+ public static final int RSV3 = 0x10;
+ // low 4 bits (0x0F) are opcode
+
+ // Second header byte
+ public static final int MASK_BIT = 0x80; // client->server payload mask bit
+ // low 7 bits (0x7F) are payload len indicator
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameOpcode.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameOpcode.java
new file mode 100644
index 0000000000..8bcdb9aaa9
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/FrameOpcode.java
@@ -0,0 +1,88 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.frame;
+
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * RFC 6455 opcode constants + helpers.
+ */
+@Internal
+public final class FrameOpcode {
+ public static final int CONT = 0x0;
+ public static final int TEXT = 0x1;
+ public static final int BINARY = 0x2;
+ public static final int CLOSE = 0x8;
+ public static final int PING = 0x9;
+ public static final int PONG = 0xA;
+
+ private FrameOpcode() {
+ }
+
+ /**
+ * Control frames have the high bit set in the low nibble (0x8–0xF).
+ */
+ public static boolean isControl(final int opcode) {
+ return (opcode & 0x08) != 0;
+ }
+
+ /**
+ * Data opcodes (not continuation).
+ */
+ public static boolean isData(final int opcode) {
+ return opcode == TEXT || opcode == BINARY;
+ }
+
+ /**
+ * Continuation opcode.
+ */
+ public static boolean isContinuation(final int opcode) {
+ return opcode == CONT;
+ }
+
+ /**
+ * Optional: human-readable name for debugging.
+ */
+ public static String name(final int opcode) {
+ switch (opcode) {
+ case CONT:
+ return "CONT";
+ case TEXT:
+ return "TEXT";
+ case BINARY:
+ return "BINARY";
+ case CLOSE:
+ return "CLOSE";
+ case PING:
+ return "PING";
+ case PONG:
+ return "PONG";
+ default:
+ return "0x" + Integer.toHexString(opcode);
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/WebSocketFrameWriter.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/WebSocketFrameWriter.java
new file mode 100644
index 0000000000..a279217b19
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/WebSocketFrameWriter.java
@@ -0,0 +1,189 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.frame;
+
+import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.FIN;
+import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.MASK_BIT;
+import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.RSV1;
+import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.RSV2;
+import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.RSV3;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ThreadLocalRandom;
+
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.websocket.message.CloseCodec;
+
+/**
+ * RFC 6455 frame writer with helpers to build into an existing target buffer.
+ *
+ * @since 5.7
+ */
+@Internal
+public final class WebSocketFrameWriter {
+
+ // -- Text/Binary -----------------------------------------------------------
+
+ public ByteBuffer text(final CharSequence data, final boolean fin) {
+ final ByteBuffer payload = data == null ? ByteBuffer.allocate(0)
+ : StandardCharsets.UTF_8.encode(data.toString());
+ // Client → server MUST be masked
+ return frame(FrameOpcode.TEXT, payload, fin, true);
+ }
+
+ public ByteBuffer binary(final ByteBuffer data, final boolean fin) {
+ final ByteBuffer payload = data == null ? ByteBuffer.allocate(0) : data.asReadOnlyBuffer();
+ return frame(FrameOpcode.BINARY, payload, fin, true);
+ }
+
+ // -- Control frames (FIN=true, payload ≤ 125, never compressed) -----------
+
+ public ByteBuffer ping(final ByteBuffer payloadOrNull) {
+ final ByteBuffer p = payloadOrNull == null ? ByteBuffer.allocate(0) : payloadOrNull.asReadOnlyBuffer();
+ if (p.remaining() > 125) {
+ throw new IllegalArgumentException("PING payload > 125 bytes");
+ }
+ return frame(FrameOpcode.PING, p, true, true);
+ }
+
+ public ByteBuffer pong(final ByteBuffer payloadOrNull) {
+ final ByteBuffer p = payloadOrNull == null ? ByteBuffer.allocate(0) : payloadOrNull.asReadOnlyBuffer();
+ if (p.remaining() > 125) {
+ throw new IllegalArgumentException("PONG payload > 125 bytes");
+ }
+ return frame(FrameOpcode.PONG, p, true, true);
+ }
+
+ public ByteBuffer close(final int code, final String reason) {
+ if (!CloseCodec.isValidToSend(code)) {
+ throw new IllegalArgumentException("Invalid close code to send: " + code);
+ }
+ final String safeReason = CloseCodec.truncateReasonUtf8(reason);
+ final ByteBuffer reasonBuf = safeReason.isEmpty()
+ ? ByteBuffer.allocate(0)
+ : StandardCharsets.UTF_8.encode(safeReason);
+
+ if (reasonBuf.remaining() > 123) {
+ throw new IllegalArgumentException("Close reason too long (UTF-8 bytes > 123)");
+ }
+
+ final ByteBuffer p = ByteBuffer.allocate(2 + reasonBuf.remaining());
+ p.put((byte) (code >> 8 & 0xFF));
+ p.put((byte) (code & 0xFF));
+ if (reasonBuf.hasRemaining()) {
+ p.put(reasonBuf);
+ }
+ p.flip();
+ return frame(FrameOpcode.CLOSE, p, true, true);
+ }
+
+ public ByteBuffer closeEcho(final ByteBuffer payload) {
+ final ByteBuffer p = payload == null ? ByteBuffer.allocate(0) : payload.asReadOnlyBuffer();
+ if (p.remaining() > 125) {
+ throw new IllegalArgumentException("Close payload > 125 bytes");
+ }
+ return frame(FrameOpcode.CLOSE, p, true, true);
+ }
+
+ // -- Core framing ----------------------------------------------------------
+
+ public ByteBuffer frame(final int opcode, final ByteBuffer payload, final boolean fin, final boolean mask) {
+ return frameWithRSV(opcode, payload, fin, mask, 0);
+ }
+
+ public ByteBuffer frameWithRSV(final int opcode, final ByteBuffer payload, final boolean fin,
+ final boolean mask, final int rsvBits) {
+ final int len = payload == null ? 0 : payload.remaining();
+ final int hdrExtra = len <= 125 ? 0 : len <= 0xFFFF ? 2 : 8;
+ final int maskLen = mask ? 4 : 0;
+ final ByteBuffer out = ByteBuffer.allocate(2 + hdrExtra + maskLen + len).order(ByteOrder.BIG_ENDIAN);
+ frameIntoWithRSV(opcode, payload, fin, mask, rsvBits, out);
+ out.flip();
+ return out;
+ }
+
+ public ByteBuffer frameInto(final int opcode, final ByteBuffer payload, final boolean fin,
+ final boolean mask, final ByteBuffer out) {
+ return frameIntoWithRSV(opcode, payload, fin, mask, 0, out);
+ }
+
+ public ByteBuffer frameIntoWithRSV(final int opcode, final ByteBuffer payload, final boolean fin,
+ final boolean mask, final int rsvBits, final ByteBuffer out) {
+ final int len = payload == null ? 0 : payload.remaining();
+
+ if (FrameOpcode.isControl(opcode)) {
+ if (!fin) {
+ throw new IllegalArgumentException("Control frames must not be fragmented (FIN=false)");
+ }
+ if (len > 125) {
+ throw new IllegalArgumentException("Control frame payload > 125 bytes");
+ }
+ if ((rsvBits & (RSV1 | RSV2 | RSV3)) != 0) {
+ throw new IllegalArgumentException("RSV bits must be 0 on control frames");
+ }
+ }
+
+ final int finBit = fin ? FIN : 0;
+ out.put((byte) (finBit | rsvBits & (RSV1 | RSV2 | RSV3) | opcode & 0x0F));
+
+ if (len <= 125) {
+ out.put((byte) ((mask ? MASK_BIT : 0) | len));
+ } else if (len <= 0xFFFF) {
+ out.put((byte) ((mask ? MASK_BIT : 0) | 126));
+ out.putShort((short) len);
+ } else {
+ out.put((byte) ((mask ? MASK_BIT : 0) | 127));
+ out.putLong(len & 0x7FFF_FFFF_FFFF_FFFFL);
+ }
+
+ int[] mkey = null;
+ if (mask) {
+ mkey = new int[]{rnd(), rnd(), rnd(), rnd()};
+ out.put((byte) mkey[0]).put((byte) mkey[1]).put((byte) mkey[2]).put((byte) mkey[3]);
+ }
+
+ if (len > 0) {
+ final ByteBuffer src = payload.asReadOnlyBuffer();
+ int i = 0; // simpler, safer mask index
+ while (src.hasRemaining()) {
+ int b = src.get() & 0xFF;
+ if (mask) {
+ b ^= mkey[i & 3];
+ i++;
+ }
+ out.put((byte) b);
+ }
+ }
+ return out;
+ }
+
+ private static int rnd() {
+ return ThreadLocalRandom.current().nextInt(256);
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/package-info.java
new file mode 100644
index 0000000000..d50e6211ee
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/frame/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * Low-level WebSocket frame helpers.
+ *
+ * Opcode constants, header bit masks, and frame writer utilities aligned
+ * with RFC 6455.
+ *
+ * @since 5.7
+ */
+package org.apache.hc.core5.websocket.frame;
\ No newline at end of file
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/message/CloseCodec.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/message/CloseCodec.java
new file mode 100644
index 0000000000..b7deeaea06
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/message/CloseCodec.java
@@ -0,0 +1,190 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.message;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * Helpers for RFC6455 CLOSE parsing & validation.
+ */
+@Internal
+public final class CloseCodec {
+
+ private CloseCodec() {
+ }
+
+
+ /**
+ * Reads the close status code from the payload buffer, if present.
+ * Returns {@code 1005} (“no status code present”) when the payload
+ * does not contain at least two bytes.
+ */
+ public static int readCloseCode(final ByteBuffer payloadRO) {
+ if (payloadRO == null || payloadRO.remaining() < 2) {
+ return 1005; // “no status code present”
+ }
+ final int b1 = payloadRO.get() & 0xFF;
+ final int b2 = payloadRO.get() & 0xFF;
+ return (b1 << 8) | b2;
+ }
+
+ /**
+ * Reads the close reason from the remaining bytes of the payload
+ * as UTF-8. Returns an empty string if there is no payload left.
+ */
+ public static String readCloseReason(final ByteBuffer payloadRO) {
+ if (payloadRO == null || !payloadRO.hasRemaining()) {
+ return "";
+ }
+ final ByteBuffer dup = payloadRO.slice();
+ return StandardCharsets.UTF_8.decode(dup).toString();
+ }
+
+ // ---- RFC validation (sender & receiver) ---------------------------------
+
+ /**
+ * RFC 6455 §7.4.2: MUST NOT appear on the wire.
+ */
+ private static boolean isForbiddenOnWire(final int code) {
+ return code == 1005 || code == 1006 || code == 1015;
+ }
+
+ /**
+ * Codes defined by RFC 6455 to send (and likewise valid to receive).
+ */
+ private static boolean isRfcDefined(final int code) {
+ switch (code) {
+ case 1000: // normal
+ case 1001: // going away
+ case 1002: // protocol error
+ case 1003: // unsupported data
+ case 1007: // invalid payload data
+ case 1008: // policy violation
+ case 1009: // message too big
+ case 1010: // mandatory extension
+ case 1011: // internal error
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Application/reserved range that may be sent by endpoints.
+ */
+ private static boolean isAppRange(final int code) {
+ return code >= 3000 && code <= 4999;
+ }
+
+ /**
+ * Validate a code we intend to PUT ON THE WIRE (sender-side).
+ */
+ public static boolean isValidToSend(final int code) {
+ if (code < 0) {
+ return false;
+ }
+ if (isForbiddenOnWire(code)) {
+ return false;
+ }
+ return isRfcDefined(code) || isAppRange(code);
+ }
+
+ /**
+ * Validate a code we PARSED FROM THE WIRE (receiver-side).
+ */
+ public static boolean isValidToReceive(final int code) {
+ // 1005, 1006, 1015 must not appear on the wire
+ if (isForbiddenOnWire(code)) {
+ return false;
+ }
+ // Same allowed sets otherwise
+ return isRfcDefined(code) || isAppRange(code);
+ }
+
+ // ---- Reason handling: max 123 bytes (2 bytes used by code) --------------
+
+ /**
+ * Returns a UTF-8 string truncated to ≤ 123 bytes, preserving code-points.
+ * This ensures that a CLOSE frame payload (2-byte status code + reason)
+ * never exceeds the 125-byte control frame limit.
+ */
+ public static String truncateReasonUtf8(final String reason) {
+ if (reason == null || reason.isEmpty()) {
+ return "";
+ }
+ final byte[] bytes = reason.getBytes(StandardCharsets.UTF_8);
+ if (bytes.length <= 123) {
+ return reason;
+ }
+ int i = 0;
+ int byteCount = 0;
+ while (i < reason.length()) {
+ final int cp = reason.codePointAt(i);
+ final int charCount = Character.charCount(cp);
+ final int extra = new String(Character.toChars(cp))
+ .getBytes(StandardCharsets.UTF_8).length;
+ if (byteCount + extra > 123) {
+ break;
+ }
+ byteCount += extra;
+ i += charCount;
+ }
+ return reason.substring(0, i);
+ }
+
+ // ---- Encoding -----------------------------------------------------------
+
+ /**
+ * Encodes a close status code and reason into a payload suitable for a
+ * CLOSE control frame:
+ *
+ *
+ * payload[0] = high-byte of status code
+ * payload[1] = low-byte of status code
+ * payload[2..] = UTF-8 bytes of the (possibly truncated) reason
+ *
+ *
+ * The reason is internally truncated to ≤ 123 UTF-8 bytes to ensure the
+ * resulting payload never exceeds the 125-byte control frame limit.
+ *
+ * The caller is expected to have already validated the status code with
+ * {@link #isValidToSend(int)}.
+ */
+ public static byte[] encode(final int statusCode, final String reason) {
+ final String truncated = truncateReasonUtf8(reason);
+ final byte[] reasonBytes = truncated.getBytes(StandardCharsets.UTF_8);
+ // 2 bytes for the status code
+ final byte[] payload = new byte[2 + reasonBytes.length];
+ payload[0] = (byte) ((statusCode >>> 8) & 0xFF);
+ payload[1] = (byte) (statusCode & 0xFF);
+ System.arraycopy(reasonBytes, 0, payload, 2, reasonBytes.length);
+ return payload;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/message/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/message/package-info.java
new file mode 100644
index 0000000000..9169316865
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/message/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * Message-level helpers and codecs.
+ *
+ *
Utilities for parsing and validating message semantics (e.g., CLOSE
+ * status code and reason handling).
+ *
+ * @since 5.7
+ */
+package org.apache.hc.core5.websocket.message;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/package-info.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/package-info.java
new file mode 100644
index 0000000000..3394c70dd2
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/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
+ * .
+ *
+ */
+
+/**
+ * WebSocket protocol support and helpers.
+ *
+ * Subpackages such as {@code frame}, {@code extension}, {@code message},
+ * {@code util}, and {@code exceptions} are internal implementation details
+ * and may change without notice.
+ *
+ * @since 5.7
+ */
+package org.apache.hc.core5.websocket;
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketContextKeys.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketContextKeys.java
new file mode 100644
index 0000000000..e006c9196b
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketContextKeys.java
@@ -0,0 +1,35 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+public final class WebSocketContextKeys {
+
+ public static final String CONNECTION = "httpcore.websocket.connection";
+
+ private WebSocketContextKeys() {
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2Server.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2Server.java
new file mode 100644
index 0000000000..0eb40d366c
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2Server.java
@@ -0,0 +1,100 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.server;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.util.concurrent.ExecutionException;
+
+import org.apache.hc.core5.http.URIScheme;
+import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncServer;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.reactor.ListenerEndpoint;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.TimeValue;
+
+public final class WebSocketH2Server {
+
+ private final HttpAsyncServer server;
+ private final InetAddress localAddress;
+ private final int port;
+ private final URIScheme scheme;
+ private volatile ListenerEndpoint endpoint;
+
+ WebSocketH2Server(final HttpAsyncServer server, final InetAddress localAddress, final int port, final URIScheme scheme) {
+ this.server = Args.notNull(server, "server");
+ this.localAddress = localAddress;
+ this.port = port;
+ this.scheme = scheme != null ? scheme : URIScheme.HTTP;
+ }
+
+ public void start() throws IOException {
+ server.start();
+ try {
+ final InetSocketAddress address = localAddress != null
+ ? new InetSocketAddress(localAddress, Math.max(port, 0))
+ : new InetSocketAddress(Math.max(port, 0));
+ this.endpoint = server.listen(address, scheme).get();
+ } catch (final ExecutionException ex) {
+ final Throwable cause = ex.getCause();
+ if (cause instanceof IOException) {
+ throw (IOException) cause;
+ }
+ throw new IOException(ex.getMessage(), ex);
+ } catch (final InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IOException(ex.getMessage(), ex);
+ }
+ }
+
+ public void stop() {
+ server.close(CloseMode.GRACEFUL);
+ }
+
+ public void initiateShutdown() {
+ server.initiateShutdown();
+ }
+
+ public InetAddress getInetAddress() {
+ if (endpoint != null && endpoint.getAddress() instanceof InetSocketAddress) {
+ return ((InetSocketAddress) endpoint.getAddress()).getAddress();
+ }
+ return localAddress;
+ }
+
+ public int getLocalPort() {
+ if (endpoint != null && endpoint.getAddress() instanceof InetSocketAddress) {
+ return ((InetSocketAddress) endpoint.getAddress()).getPort();
+ }
+ return port;
+ }
+
+ public void awaitShutdown(final TimeValue waitTime) throws InterruptedException {
+ server.awaitShutdown(waitTime);
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerBootstrap.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerBootstrap.java
new file mode 100644
index 0000000000..6156be409f
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerBootstrap.java
@@ -0,0 +1,230 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import java.net.InetAddress;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+
+import org.apache.hc.core5.function.Supplier;
+import org.apache.hc.core5.http.URIScheme;
+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.bootstrap.HttpAsyncServer;
+import org.apache.hc.core5.http.impl.routing.RequestRouter;
+import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler;
+import org.apache.hc.core5.http.nio.ssl.TlsStrategy;
+import org.apache.hc.core5.http.protocol.HttpProcessor;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
+import org.apache.hc.core5.http2.config.H2Config;
+import org.apache.hc.core5.http2.impl.nio.H2StreamListener;
+import org.apache.hc.core5.http2.impl.nio.bootstrap.H2ServerBootstrap;
+import org.apache.hc.core5.reactor.IOReactorConfig;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.Timeout;
+import org.apache.hc.core5.websocket.WebSocketConfig;
+import org.apache.hc.core5.websocket.WebSocketExtensionRegistry;
+import org.apache.hc.core5.websocket.WebSocketHandler;
+
+/**
+ * Bootstrap for HTTP/2 WebSocket servers using RFC 8441 (Extended CONNECT).
+ *
+ * @since 5.7
+ */
+public final class WebSocketH2ServerBootstrap {
+
+ private final List>> routeEntries;
+ private String canonicalHostName;
+ private int listenerPort;
+ private InetAddress localAddress;
+ private IOReactorConfig ioReactorConfig;
+ private HttpProcessor httpProcessor;
+ private HttpVersionPolicy versionPolicy;
+ private H2Config h2Config = H2Config.custom().setPushEnabled(false).build();
+ private Http1Config http1Config = Http1Config.DEFAULT;
+ private CharCodingConfig charCodingConfig;
+ private TlsStrategy tlsStrategy;
+ private Timeout handshakeTimeout;
+ private H2StreamListener h2StreamListener;
+ private Http1StreamListener http1StreamListener;
+ private WebSocketConfig webSocketConfig;
+ private WebSocketExtensionRegistry extensionRegistry;
+ private Executor executor;
+
+ private WebSocketH2ServerBootstrap() {
+ this.routeEntries = new ArrayList<>();
+ }
+
+ public static WebSocketH2ServerBootstrap bootstrap() {
+ return new WebSocketH2ServerBootstrap();
+ }
+
+ public WebSocketH2ServerBootstrap setCanonicalHostName(final String canonicalHostName) {
+ this.canonicalHostName = canonicalHostName;
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap setListenerPort(final int listenerPort) {
+ this.listenerPort = listenerPort;
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap setLocalAddress(final InetAddress localAddress) {
+ this.localAddress = localAddress;
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap setIOReactorConfig(final IOReactorConfig ioReactorConfig) {
+ this.ioReactorConfig = ioReactorConfig;
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap setHttpProcessor(final HttpProcessor httpProcessor) {
+ this.httpProcessor = httpProcessor;
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap setVersionPolicy(final HttpVersionPolicy versionPolicy) {
+ this.versionPolicy = versionPolicy;
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap setH2Config(final H2Config h2Config) {
+ if (h2Config == null) {
+ this.h2Config = H2Config.custom().setPushEnabled(false).build();
+ } else if (h2Config.isPushEnabled()) {
+ this.h2Config = H2Config.copy(h2Config).setPushEnabled(false).build();
+ } else {
+ this.h2Config = h2Config;
+ }
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap setHttp1Config(final Http1Config http1Config) {
+ this.http1Config = http1Config != null ? http1Config : Http1Config.DEFAULT;
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap setCharCodingConfig(final CharCodingConfig charCodingConfig) {
+ this.charCodingConfig = charCodingConfig;
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap setTlsStrategy(final TlsStrategy tlsStrategy) {
+ this.tlsStrategy = tlsStrategy;
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap setHandshakeTimeout(final Timeout handshakeTimeout) {
+ this.handshakeTimeout = handshakeTimeout;
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap setH2StreamListener(final H2StreamListener h2StreamListener) {
+ this.h2StreamListener = h2StreamListener;
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap setHttp1StreamListener(final Http1StreamListener http1StreamListener) {
+ this.http1StreamListener = http1StreamListener;
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap setWebSocketConfig(final WebSocketConfig webSocketConfig) {
+ this.webSocketConfig = webSocketConfig;
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap setExtensionRegistry(final WebSocketExtensionRegistry extensionRegistry) {
+ this.extensionRegistry = extensionRegistry;
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap setExecutor(final Executor executor) {
+ this.executor = executor;
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap register(final String uriPattern, final Supplier supplier) {
+ Args.notNull(uriPattern, "URI pattern");
+ Args.notNull(supplier, "WebSocket handler supplier");
+ this.routeEntries.add(new RequestRouter.Entry<>(uriPattern, supplier));
+ return this;
+ }
+
+ public WebSocketH2ServerBootstrap register(final String hostname, final String uriPattern, final Supplier supplier) {
+ Args.notNull(hostname, "Hostname");
+ Args.notNull(uriPattern, "URI pattern");
+ Args.notNull(supplier, "WebSocket handler supplier");
+ this.routeEntries.add(new RequestRouter.Entry<>(hostname, uriPattern, supplier));
+ return this;
+ }
+
+ public WebSocketH2Server create() {
+ final WebSocketConfig cfg = webSocketConfig != null ? webSocketConfig : WebSocketConfig.DEFAULT;
+ final WebSocketExtensionRegistry ext = extensionRegistry != null
+ ? extensionRegistry
+ : WebSocketExtensionRegistry.createDefault();
+
+ final H2ServerBootstrap bootstrap = H2ServerBootstrap.bootstrap()
+ .setCanonicalHostName(canonicalHostName)
+ .setIOReactorConfig(ioReactorConfig)
+ .setHttpProcessor(httpProcessor)
+ .setVersionPolicy(versionPolicy != null ? versionPolicy : HttpVersionPolicy.FORCE_HTTP_2)
+ .setTlsStrategy(tlsStrategy)
+ .setHandshakeTimeout(handshakeTimeout)
+ .setStreamListener(h2StreamListener)
+ .setStreamListener(http1StreamListener);
+
+ if (h2Config != null) {
+ bootstrap.setH2Config(h2Config);
+ }
+ if (http1Config != null) {
+ bootstrap.setHttp1Config(http1Config);
+ }
+
+ if (charCodingConfig != null) {
+ bootstrap.setCharset(charCodingConfig);
+ }
+
+ for (final RequestRouter.Entry> entry : routeEntries) {
+ final Supplier exchangeSupplier = () ->
+ new WebSocketH2ServerExchangeHandler(entry.route.handler.get(), cfg, ext, executor);
+ if (entry.uriAuthority != null) {
+ bootstrap.register(entry.uriAuthority.getHostName(), entry.route.pattern, exchangeSupplier);
+ } else {
+ bootstrap.register(entry.route.pattern, exchangeSupplier);
+ }
+ }
+
+ final HttpAsyncServer server = bootstrap.create();
+ final URIScheme scheme = tlsStrategy != null ? URIScheme.HTTPS : URIScheme.HTTP;
+ return new WebSocketH2Server(server, localAddress, listenerPort, scheme);
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandler.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandler.java
new file mode 100644
index 0000000000..dad8631510
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandler.java
@@ -0,0 +1,379 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.apache.hc.core5.concurrent.DefaultThreadFactory;
+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.HttpRequest;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.Method;
+import org.apache.hc.core5.http.impl.BasicEntityDetails;
+import org.apache.hc.core5.http.message.BasicHttpResponse;
+import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler;
+import org.apache.hc.core5.http.nio.CapacityChannel;
+import org.apache.hc.core5.http.nio.DataStreamChannel;
+import org.apache.hc.core5.http.nio.ResponseChannel;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.websocket.WebSocketCloseStatus;
+import org.apache.hc.core5.websocket.WebSocketConfig;
+import org.apache.hc.core5.websocket.WebSocketConstants;
+import org.apache.hc.core5.websocket.WebSocketExtensionNegotiation;
+import org.apache.hc.core5.websocket.WebSocketExtensionRegistry;
+import org.apache.hc.core5.websocket.WebSocketExtensions;
+import org.apache.hc.core5.websocket.WebSocketHandler;
+import org.apache.hc.core5.websocket.WebSocketHandshake;
+import org.apache.hc.core5.websocket.WebSocketSession;
+
+final class WebSocketH2ServerExchangeHandler implements AsyncServerExchangeHandler {
+
+ private static final byte[] END_INBOUND = new byte[0];
+ private static final ByteBuffer END_OUTBOUND = ByteBuffer.allocate(0);
+
+ /**
+ * Default execution strategy (no explicit thread creation in the handler).
+ * Note: tasks are typically long-lived (one per WS session). The bootstrap should ideally inject an executor.
+ */
+ private static final Executor DEFAULT_EXECUTOR =
+ Executors.newCachedThreadPool(new DefaultThreadFactory("ws-h2-server", true));
+
+ private final WebSocketHandler handler;
+ private final WebSocketConfig config;
+ private final WebSocketExtensionRegistry extensionRegistry;
+ private final Executor executor;
+
+ private final BlockingQueue inbound = new LinkedBlockingQueue<>();
+ private final BlockingQueue outbound = new LinkedBlockingQueue<>();
+
+ private final ReentrantLock outLock = new ReentrantLock();
+ private ByteBuffer currentOutbound;
+
+ private volatile boolean responseSent;
+ private volatile boolean outboundEnd;
+ private volatile boolean shutdown;
+ private volatile DataStreamChannel dataChannel;
+
+ WebSocketH2ServerExchangeHandler(
+ final WebSocketHandler handler,
+ final WebSocketConfig config,
+ final WebSocketExtensionRegistry extensionRegistry) {
+ this(handler, config, extensionRegistry, null);
+ }
+
+ WebSocketH2ServerExchangeHandler(
+ final WebSocketHandler handler,
+ final WebSocketConfig config,
+ final WebSocketExtensionRegistry extensionRegistry,
+ final Executor executor) {
+ this.handler = Args.notNull(handler, "WebSocket handler");
+ this.config = config != null ? config : WebSocketConfig.DEFAULT;
+ this.extensionRegistry = extensionRegistry != null ? extensionRegistry : WebSocketExtensionRegistry.createDefault();
+ this.executor = executor != null ? executor : DEFAULT_EXECUTOR;
+ this.responseSent = false;
+ this.outboundEnd = false;
+ this.shutdown = false;
+ }
+
+ @Override
+ public void handleRequest(
+ final HttpRequest request,
+ final EntityDetails entityDetails,
+ final ResponseChannel responseChannel,
+ final HttpContext context) throws HttpException, IOException {
+
+ if (!Method.CONNECT.isSame(request.getMethod())) {
+ responseChannel.sendResponse(new BasicHttpResponse(HttpStatus.SC_BAD_REQUEST), null, context);
+ return;
+ }
+
+ final String protocol = request.getFirstHeader(WebSocketConstants.PSEUDO_PROTOCOL) != null
+ ? request.getFirstHeader(WebSocketConstants.PSEUDO_PROTOCOL).getValue()
+ : null;
+ if (!"websocket".equalsIgnoreCase(protocol)) {
+ responseChannel.sendResponse(new BasicHttpResponse(HttpStatus.SC_BAD_REQUEST), null, context);
+ return;
+ }
+
+ final WebSocketExtensionNegotiation negotiation = extensionRegistry.negotiate(
+ WebSocketExtensions.parse(request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS_LOWER)),
+ true);
+
+ final BasicHttpResponse response = new BasicHttpResponse(HttpStatus.SC_OK);
+ final String extensionsHeader = negotiation.formatResponseHeader();
+ if (extensionsHeader != null) {
+ response.addHeader(WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS_LOWER, extensionsHeader);
+ }
+
+ final List offeredProtocols = WebSocketHandshake.parseSubprotocols(
+ request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL_LOWER));
+ final String protocolResponse = handler.selectSubprotocol(offeredProtocols);
+ if (protocolResponse != null) {
+ response.addHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL_LOWER, protocolResponse);
+ }
+
+ responseChannel.sendResponse(response, new BasicEntityDetails(-1, null), context);
+ responseSent = true;
+
+ final InputStream inputStream = new QueueInputStream(inbound);
+ final OutputStream outputStream = new QueueOutputStream(outbound);
+ final WebSocketSession session = new WebSocketSession(
+ config, inputStream, outputStream, null, null, negotiation.getExtensions());
+
+ executor.execute(() -> {
+ try {
+ handler.onOpen(session);
+ new WebSocketServerProcessor(session, handler, config.getMaxMessageSize()).process();
+ } catch (final Exception ex) {
+ handler.onError(session, ex);
+ try {
+ session.close(WebSocketCloseStatus.INTERNAL_ERROR.getCode(), "WebSocket error");
+ } catch (final IOException ignore) {
+ // ignore
+ }
+ } finally {
+ shutdown = true;
+ outbound.offer(END_OUTBOUND);
+ inbound.offer(END_INBOUND);
+
+ final DataStreamChannel channel = dataChannel;
+ if (channel != null) {
+ channel.requestOutput();
+ }
+ }
+ });
+ }
+
+ @Override
+ public void updateCapacity(final CapacityChannel capacityChannel) throws IOException {
+ capacityChannel.update(Integer.MAX_VALUE);
+ }
+
+ @Override
+ public void consume(final ByteBuffer src) throws IOException {
+ if (src == null || !src.hasRemaining() || shutdown) {
+ return;
+ }
+ final byte[] data = new byte[src.remaining()];
+ src.get(data);
+ inbound.offer(data);
+ }
+
+ @Override
+ public void streamEnd(final List extends Header> trailers) throws HttpException, IOException {
+ inbound.offer(END_INBOUND);
+ }
+
+ @Override
+ public int available() {
+ if (!responseSent || outboundEnd) {
+ return 0;
+ }
+ final ByteBuffer next;
+ outLock.lock();
+ try {
+ next = currentOutbound != null ? currentOutbound : outbound.peek();
+ } finally {
+ outLock.unlock();
+ }
+ if (next == null) {
+ return 0;
+ }
+ if (next == END_OUTBOUND) {
+ // Force produce() so we can emit END_STREAM.
+ return 1;
+ }
+ return next.remaining();
+ }
+
+ @Override
+ public void produce(final DataStreamChannel channel) throws IOException {
+ if (!responseSent || outboundEnd) {
+ return;
+ }
+ this.dataChannel = channel;
+
+ for (; ; ) {
+ final ByteBuffer buf;
+ outLock.lock();
+ try {
+ if (currentOutbound == null) {
+ currentOutbound = outbound.poll();
+ }
+ buf = currentOutbound;
+ } finally {
+ outLock.unlock();
+ }
+ if (buf == null) {
+ return;
+ }
+
+ if (buf == END_OUTBOUND) {
+ outLock.lock();
+ try {
+ currentOutbound = null;
+ } finally {
+ outLock.unlock();
+ }
+ outboundEnd = true;
+ channel.endStream(null);
+ return;
+ }
+
+ if (!buf.hasRemaining()) {
+ outLock.lock();
+ try {
+ currentOutbound = null;
+ } finally {
+ outLock.unlock();
+ }
+ continue;
+ }
+
+ final int n = channel.write(buf);
+ if (n == 0) {
+ channel.requestOutput();
+ return;
+ }
+ if (buf.hasRemaining()) {
+ channel.requestOutput();
+ return;
+ }
+
+ outLock.lock();
+ try {
+ currentOutbound = null;
+ } finally {
+ outLock.unlock();
+ }
+ }
+ }
+
+ @Override
+ public void failed(final Exception cause) {
+ shutdown = true;
+ outbound.offer(END_OUTBOUND);
+ inbound.offer(END_INBOUND);
+
+ final DataStreamChannel channel = dataChannel;
+ if (channel != null) {
+ channel.requestOutput();
+ }
+ }
+
+ @Override
+ public void releaseResources() {
+ shutdown = true;
+ outbound.clear();
+ inbound.clear();
+ outLock.lock();
+ try {
+ currentOutbound = null;
+ } finally {
+ outLock.unlock();
+ }
+ }
+
+ private static final class QueueInputStream extends InputStream {
+
+ private final BlockingQueue queue;
+ private byte[] current;
+ private int pos;
+
+ QueueInputStream(final BlockingQueue queue) {
+ this.queue = queue;
+ }
+
+ @Override
+ public int read() throws IOException {
+ if (current == null || pos >= current.length) {
+ try {
+ current = queue.take();
+ } catch (final InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IOException(ex.getMessage(), ex);
+ }
+ pos = 0;
+ if (current == END_INBOUND) {
+ return -1;
+ }
+ }
+ return current[pos++] & 0xFF;
+ }
+ }
+
+ private final class QueueOutputStream extends OutputStream {
+
+ private final BlockingQueue queue;
+
+ QueueOutputStream(final BlockingQueue queue) {
+ this.queue = queue;
+ }
+
+ @Override
+ public void write(final int b) throws IOException {
+ queue.offer(ByteBuffer.wrap(new byte[]{(byte) b}));
+ requestOutput();
+ }
+
+ @Override
+ public void write(final byte[] b, final int off, final int len) throws IOException {
+ if (len == 0) {
+ return;
+ }
+ final byte[] copy = new byte[len];
+ System.arraycopy(b, off, copy, 0, len);
+ queue.offer(ByteBuffer.wrap(copy));
+ requestOutput();
+ }
+
+ @Override
+ public void close() {
+ queue.offer(END_OUTBOUND);
+ requestOutput();
+ }
+
+ private void requestOutput() {
+ final DataStreamChannel channel = dataChannel;
+ if (responseSent && channel != null) {
+ channel.requestOutput();
+ }
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketHttpService.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketHttpService.java
new file mode 100644
index 0000000000..3caee56c7d
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketHttpService.java
@@ -0,0 +1,68 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import java.io.IOException;
+
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.impl.DefaultConnectionReuseStrategy;
+import org.apache.hc.core5.http.impl.io.HttpService;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.http.protocol.HttpProcessor;
+import org.apache.hc.core5.http.io.HttpServerConnection;
+import org.apache.hc.core5.http.io.HttpServerRequestHandler;
+import org.apache.hc.core5.http.config.Http1Config;
+import org.apache.hc.core5.http.ConnectionReuseStrategy;
+import org.apache.hc.core5.http.impl.Http1StreamListener;
+import org.apache.hc.core5.util.Args;
+
+class WebSocketHttpService extends HttpService {
+
+ WebSocketHttpService(
+ final HttpProcessor processor,
+ final HttpServerRequestHandler requestHandler,
+ final Http1Config http1Config,
+ final ConnectionReuseStrategy connReuseStrategy,
+ final Http1StreamListener streamListener) {
+ super(
+ Args.notNull(processor, "HTTP processor"),
+ Args.notNull(requestHandler, "Request handler"),
+ http1Config != null ? http1Config : Http1Config.DEFAULT,
+ connReuseStrategy != null ? connReuseStrategy : DefaultConnectionReuseStrategy.INSTANCE,
+ streamListener);
+ }
+
+ @Override
+ public void handleRequest(
+ final HttpServerConnection conn,
+ final HttpContext localContext) throws IOException, HttpException {
+ if (localContext != null) {
+ localContext.setAttribute(WebSocketContextKeys.CONNECTION, conn);
+ }
+ super.handleRequest(conn, localContext);
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServer.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServer.java
new file mode 100644
index 0000000000..eacb15e118
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServer.java
@@ -0,0 +1,61 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import java.io.IOException;
+import java.net.InetAddress;
+
+import org.apache.hc.core5.http.impl.bootstrap.HttpServer;
+
+public final class WebSocketServer {
+
+ private final HttpServer httpServer;
+
+ WebSocketServer(final HttpServer httpServer) {
+ this.httpServer = httpServer;
+ }
+
+ public void start() throws IOException {
+ httpServer.start();
+ }
+
+ public void stop() {
+ httpServer.stop();
+ }
+
+ public void initiateShutdown() {
+ httpServer.initiateShutdown();
+ }
+
+ public InetAddress getInetAddress() {
+ return httpServer.getInetAddress();
+ }
+
+ public int getLocalPort() {
+ return httpServer.getLocalPort();
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrap.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrap.java
new file mode 100644
index 0000000000..8c7001dcb8
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrap.java
@@ -0,0 +1,250 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import java.net.InetAddress;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiFunction;
+import java.util.function.Supplier;
+
+import javax.net.ServerSocketFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLParameters;
+import javax.net.ssl.SSLServerSocketFactory;
+
+import org.apache.hc.core5.function.Callback;
+import org.apache.hc.core5.http.ConnectionReuseStrategy;
+import org.apache.hc.core5.http.ExceptionListener;
+import org.apache.hc.core5.http.HttpRequestMapper;
+import org.apache.hc.core5.http.URIScheme;
+import org.apache.hc.core5.http.config.CharCodingConfig;
+import org.apache.hc.core5.http.config.Http1Config;
+import org.apache.hc.core5.http.impl.DefaultConnectionReuseStrategy;
+import org.apache.hc.core5.http.impl.Http1StreamListener;
+import org.apache.hc.core5.http.impl.HttpProcessors;
+import org.apache.hc.core5.http.impl.bootstrap.HttpServer;
+import org.apache.hc.core5.http.impl.routing.RequestRouter;
+import org.apache.hc.core5.http.io.HttpConnectionFactory;
+import org.apache.hc.core5.http.io.SocketConfig;
+import org.apache.hc.core5.http.io.ssl.DefaultTlsSetupHandler;
+import org.apache.hc.core5.http.protocol.HttpProcessor;
+import org.apache.hc.core5.http.protocol.UriPatternType;
+import org.apache.hc.core5.net.InetAddressUtils;
+import org.apache.hc.core5.net.URIAuthority;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.websocket.WebSocketConfig;
+import org.apache.hc.core5.websocket.WebSocketExtensionRegistry;
+import org.apache.hc.core5.websocket.WebSocketHandler;
+
+public class WebSocketServerBootstrap {
+
+ private final List>> routeEntries;
+ private String canonicalHostName;
+ private int listenerPort;
+ private InetAddress localAddress;
+ private SocketConfig socketConfig;
+ private Http1Config http1Config;
+ private CharCodingConfig charCodingConfig;
+ private HttpProcessor httpProcessor;
+ private ConnectionReuseStrategy connStrategy;
+ private ServerSocketFactory serverSocketFactory;
+ private SSLContext sslContext;
+ private Callback sslSetupHandler;
+ private ExceptionListener exceptionListener;
+ private Http1StreamListener streamListener;
+ private BiFunction authorityResolver;
+ private HttpRequestMapper> requestRouter;
+ private WebSocketConfig webSocketConfig;
+ private HttpConnectionFactory extends WebSocketServerConnection> connectionFactory;
+ private WebSocketExtensionRegistry extensionRegistry;
+
+ private WebSocketServerBootstrap() {
+ this.routeEntries = new ArrayList<>();
+ }
+
+ public static WebSocketServerBootstrap bootstrap() {
+ return new WebSocketServerBootstrap();
+ }
+
+ public WebSocketServerBootstrap setCanonicalHostName(final String canonicalHostName) {
+ this.canonicalHostName = canonicalHostName;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setListenerPort(final int listenerPort) {
+ this.listenerPort = listenerPort;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setLocalAddress(final InetAddress localAddress) {
+ this.localAddress = localAddress;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setSocketConfig(final SocketConfig socketConfig) {
+ this.socketConfig = socketConfig;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setHttp1Config(final Http1Config http1Config) {
+ this.http1Config = http1Config;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setCharCodingConfig(final CharCodingConfig charCodingConfig) {
+ this.charCodingConfig = charCodingConfig;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setHttpProcessor(final HttpProcessor httpProcessor) {
+ this.httpProcessor = httpProcessor;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setConnectionReuseStrategy(final ConnectionReuseStrategy connStrategy) {
+ this.connStrategy = connStrategy;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setServerSocketFactory(final ServerSocketFactory serverSocketFactory) {
+ this.serverSocketFactory = serverSocketFactory;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setSslContext(final SSLContext sslContext) {
+ this.sslContext = sslContext;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setSslSetupHandler(final Callback sslSetupHandler) {
+ this.sslSetupHandler = sslSetupHandler;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setExceptionListener(final ExceptionListener exceptionListener) {
+ this.exceptionListener = exceptionListener;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setStreamListener(final Http1StreamListener streamListener) {
+ this.streamListener = streamListener;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setAuthorityResolver(final BiFunction authorityResolver) {
+ this.authorityResolver = authorityResolver;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setRequestRouter(final HttpRequestMapper> requestRouter) {
+ this.requestRouter = requestRouter;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setWebSocketConfig(final WebSocketConfig webSocketConfig) {
+ this.webSocketConfig = webSocketConfig;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setExtensionRegistry(final WebSocketExtensionRegistry extensionRegistry) {
+ this.extensionRegistry = extensionRegistry;
+ return this;
+ }
+
+ public WebSocketServerBootstrap setConnectionFactory(
+ final HttpConnectionFactory extends WebSocketServerConnection> connectionFactory) {
+ this.connectionFactory = connectionFactory;
+ return this;
+ }
+
+ public WebSocketServerBootstrap register(final String uriPattern, final Supplier supplier) {
+ Args.notNull(uriPattern, "URI pattern");
+ Args.notNull(supplier, "WebSocket handler supplier");
+ this.routeEntries.add(new RequestRouter.Entry<>(uriPattern, supplier));
+ return this;
+ }
+
+ public WebSocketServerBootstrap register(final String hostname, final String uriPattern, final Supplier supplier) {
+ Args.notNull(hostname, "Hostname");
+ Args.notNull(uriPattern, "URI pattern");
+ Args.notNull(supplier, "WebSocket handler supplier");
+ this.routeEntries.add(new RequestRouter.Entry<>(hostname, uriPattern, supplier));
+ return this;
+ }
+
+ public WebSocketServer create() {
+ final String actualCanonicalHostName = canonicalHostName != null ? canonicalHostName : InetAddressUtils.getCanonicalLocalHostName();
+ final HttpRequestMapper> requestRouterCopy;
+ if (routeEntries.isEmpty()) {
+ requestRouterCopy = requestRouter;
+ } else {
+ requestRouterCopy = RequestRouter.create(
+ new URIAuthority(actualCanonicalHostName),
+ UriPatternType.URI_PATTERN,
+ routeEntries,
+ authorityResolver != null ? authorityResolver : RequestRouter.IGNORE_PORT_AUTHORITY_RESOLVER,
+ requestRouter);
+ }
+ final HttpRequestMapper> router = requestRouterCopy != null ? requestRouterCopy : (r, c) -> null;
+
+ final WebSocketExtensionRegistry extensions = extensionRegistry != null
+ ? extensionRegistry
+ : WebSocketExtensionRegistry.createDefault();
+ final WebSocketServerRequestHandler requestHandler = new WebSocketServerRequestHandler(
+ router,
+ webSocketConfig != null ? webSocketConfig : WebSocketConfig.DEFAULT,
+ extensions);
+
+ final HttpProcessor processor = httpProcessor != null ? httpProcessor : HttpProcessors.server();
+ final WebSocketHttpService httpService = new WebSocketHttpService(
+ processor,
+ requestHandler,
+ http1Config,
+ connStrategy != null ? connStrategy : DefaultConnectionReuseStrategy.INSTANCE,
+ streamListener);
+
+ HttpConnectionFactory extends WebSocketServerConnection> connectionFactoryCopy = this.connectionFactory;
+ if (connectionFactoryCopy == null) {
+ final String scheme = serverSocketFactory instanceof SSLServerSocketFactory || sslContext != null ?
+ URIScheme.HTTPS.id : URIScheme.HTTP.id;
+ connectionFactoryCopy = new WebSocketServerConnectionFactory(scheme, http1Config, charCodingConfig);
+ }
+
+ final HttpServer httpServer = new HttpServer(
+ Math.max(this.listenerPort, 0),
+ httpService,
+ this.localAddress,
+ this.socketConfig != null ? this.socketConfig : SocketConfig.DEFAULT,
+ serverSocketFactory,
+ connectionFactoryCopy,
+ sslContext,
+ sslSetupHandler != null ? sslSetupHandler : DefaultTlsSetupHandler.SERVER,
+ this.exceptionListener != null ? this.exceptionListener : ExceptionListener.NO_OP);
+ return new WebSocketServer(httpServer);
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnection.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnection.java
new file mode 100644
index 0000000000..ac0cc48aee
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnection.java
@@ -0,0 +1,68 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CharsetEncoder;
+
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.ContentLengthStrategy;
+import org.apache.hc.core5.http.config.Http1Config;
+import org.apache.hc.core5.http.impl.io.DefaultBHttpServerConnection;
+import org.apache.hc.core5.http.impl.io.SocketHolder;
+import org.apache.hc.core5.http.io.HttpMessageParserFactory;
+import org.apache.hc.core5.http.io.HttpMessageWriterFactory;
+
+class WebSocketServerConnection extends DefaultBHttpServerConnection {
+
+ WebSocketServerConnection(
+ final String scheme,
+ final Http1Config http1Config,
+ final CharsetDecoder charDecoder,
+ final CharsetEncoder charEncoder,
+ final ContentLengthStrategy incomingContentStrategy,
+ final ContentLengthStrategy outgoingContentStrategy,
+ final HttpMessageParserFactory requestParserFactory,
+ final HttpMessageWriterFactory responseWriterFactory) {
+ super(scheme, http1Config, charDecoder, charEncoder, incomingContentStrategy, outgoingContentStrategy,
+ requestParserFactory, responseWriterFactory);
+ }
+
+ InputStream getSocketInputStream() throws IOException {
+ final SocketHolder socketHolder = getSocketHolder();
+ return socketHolder != null ? socketHolder.getInputStream() : null;
+ }
+
+ OutputStream getSocketOutputStream() throws IOException {
+ final SocketHolder socketHolder = getSocketHolder();
+ return socketHolder != null ? socketHolder.getOutputStream() : null;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactory.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactory.java
new file mode 100644
index 0000000000..51e466b6ed
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactory.java
@@ -0,0 +1,105 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import java.io.IOException;
+import java.net.Socket;
+
+import javax.net.ssl.SSLSocket;
+
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.ContentLengthStrategy;
+import org.apache.hc.core5.http.URIScheme;
+import org.apache.hc.core5.http.config.CharCodingConfig;
+import org.apache.hc.core5.http.config.Http1Config;
+import org.apache.hc.core5.http.impl.CharCodingSupport;
+import org.apache.hc.core5.http.impl.io.DefaultHttpRequestParserFactory;
+import org.apache.hc.core5.http.impl.io.DefaultHttpResponseWriterFactory;
+import org.apache.hc.core5.http.io.HttpConnectionFactory;
+import org.apache.hc.core5.http.io.HttpMessageParserFactory;
+import org.apache.hc.core5.http.io.HttpMessageWriterFactory;
+
+class WebSocketServerConnectionFactory implements HttpConnectionFactory {
+
+ private final String scheme;
+ private final Http1Config http1Config;
+ private final CharCodingConfig charCodingConfig;
+ private final ContentLengthStrategy incomingContentStrategy;
+ private final ContentLengthStrategy outgoingContentStrategy;
+ private final HttpMessageParserFactory requestParserFactory;
+ private final HttpMessageWriterFactory responseWriterFactory;
+
+ WebSocketServerConnectionFactory(
+ final String scheme,
+ final Http1Config http1Config,
+ final CharCodingConfig charCodingConfig,
+ final ContentLengthStrategy incomingContentStrategy,
+ final ContentLengthStrategy outgoingContentStrategy,
+ final HttpMessageParserFactory requestParserFactory,
+ final HttpMessageWriterFactory responseWriterFactory) {
+ this.scheme = scheme;
+ this.http1Config = http1Config != null ? http1Config : Http1Config.DEFAULT;
+ this.charCodingConfig = charCodingConfig != null ? charCodingConfig : CharCodingConfig.DEFAULT;
+ this.incomingContentStrategy = incomingContentStrategy;
+ this.outgoingContentStrategy = outgoingContentStrategy;
+ this.requestParserFactory = requestParserFactory != null ? requestParserFactory :
+ new DefaultHttpRequestParserFactory(this.http1Config);
+ this.responseWriterFactory = responseWriterFactory != null ? responseWriterFactory :
+ new DefaultHttpResponseWriterFactory(this.http1Config);
+ }
+
+ WebSocketServerConnectionFactory(final String scheme, final Http1Config http1Config, final CharCodingConfig charCodingConfig) {
+ this(scheme, http1Config, charCodingConfig, null, null, null, null);
+ }
+
+ private WebSocketServerConnection createDetached(final Socket socket) {
+ return new WebSocketServerConnection(
+ scheme != null ? scheme : (socket instanceof SSLSocket ? URIScheme.HTTPS.id : URIScheme.HTTP.id),
+ this.http1Config,
+ CharCodingSupport.createDecoder(this.charCodingConfig),
+ CharCodingSupport.createEncoder(this.charCodingConfig),
+ this.incomingContentStrategy,
+ this.outgoingContentStrategy,
+ this.requestParserFactory,
+ this.responseWriterFactory);
+ }
+
+ @Override
+ public WebSocketServerConnection createConnection(final Socket socket) throws IOException {
+ final WebSocketServerConnection conn = createDetached(socket);
+ conn.bind(socket);
+ return conn;
+ }
+
+ @Override
+ public WebSocketServerConnection createConnection(final SSLSocket sslSocket, final Socket socket) throws IOException {
+ final WebSocketServerConnection conn = createDetached(sslSocket);
+ conn.bind(sslSocket, socket);
+ return conn;
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessor.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessor.java
new file mode 100644
index 0000000000..7208bb3a62
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessor.java
@@ -0,0 +1,163 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.websocket.WebSocketCloseStatus;
+import org.apache.hc.core5.websocket.WebSocketException;
+import org.apache.hc.core5.websocket.WebSocketFrame;
+import org.apache.hc.core5.websocket.WebSocketFrameType;
+import org.apache.hc.core5.websocket.WebSocketHandler;
+import org.apache.hc.core5.websocket.WebSocketSession;
+import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException;
+import org.apache.hc.core5.websocket.message.CloseCodec;
+
+class WebSocketServerProcessor {
+
+ private final WebSocketSession session;
+ private final WebSocketHandler handler;
+ private final int maxMessageSize;
+
+ WebSocketServerProcessor(final WebSocketSession session, final WebSocketHandler handler, final int maxMessageSize) {
+ this.session = Args.notNull(session, "WebSocket session");
+ this.handler = Args.notNull(handler, "WebSocket handler");
+ this.maxMessageSize = maxMessageSize;
+ }
+
+ void process() throws IOException {
+ ByteArrayOutputStream continuationBuffer = null;
+ WebSocketFrameType continuationType = null;
+ while (true) {
+ final WebSocketFrame frame = session.readFrame();
+ if (frame == null) {
+ break;
+ }
+ if (frame.isRsv2() || frame.isRsv3()) {
+ throw new WebSocketException("Unsupported RSV bits");
+ }
+ final WebSocketFrameType type = frame.getType();
+ final int payloadLen = frame.getPayload().remaining();
+ if (type == WebSocketFrameType.CLOSE
+ || type == WebSocketFrameType.PING
+ || type == WebSocketFrameType.PONG) {
+ if (!frame.isFin() || payloadLen > 125) {
+ throw new WebSocketException("Invalid control frame");
+ }
+ }
+ switch (type) {
+ case PING:
+ handler.onPing(session, frame.getPayload());
+ session.sendPong(frame.getPayload());
+ break;
+ case PONG:
+ handler.onPong(session, frame.getPayload());
+ break;
+ case CLOSE:
+ handleCloseFrame(frame);
+ return;
+ case TEXT:
+ case BINARY:
+ if (frame.isFin()) {
+ dispatchMessage(type, frame.getPayload());
+ } else {
+ continuationBuffer = startContinuation(type, frame.getPayload());
+ continuationType = type;
+ }
+ break;
+ case CONTINUATION:
+ if (continuationBuffer == null || continuationType == null) {
+ throw new WebSocketException("Unexpected continuation frame");
+ }
+ appendContinuation(continuationBuffer, frame.getPayload());
+ if (frame.isFin()) {
+ final ByteBuffer payload = ByteBuffer.wrap(continuationBuffer.toByteArray());
+ dispatchMessage(continuationType, payload);
+ continuationBuffer = null;
+ continuationType = null;
+ }
+ break;
+ default:
+ throw new WebSocketException("Unsupported frame type: " + type);
+ }
+ }
+ }
+
+ private void dispatchMessage(final WebSocketFrameType type, final ByteBuffer payload) throws IOException {
+ if (payload.remaining() > maxMessageSize) {
+ throw new WebSocketProtocolException(1009, "Message too large: " + payload.remaining());
+ }
+ if (type == WebSocketFrameType.TEXT) {
+ handler.onText(session, WebSocketSession.decodeText(payload));
+ } else {
+ handler.onBinary(session, payload);
+ }
+ }
+
+ private ByteArrayOutputStream startContinuation(final WebSocketFrameType type, final ByteBuffer payload) throws WebSocketException {
+ if (payload.remaining() > maxMessageSize) {
+ throw new WebSocketProtocolException(1009, "Message too large: " + payload.remaining());
+ }
+ final ByteArrayOutputStream buffer = new ByteArrayOutputStream(payload.remaining());
+ appendContinuation(buffer, payload);
+ return buffer;
+ }
+
+ private void appendContinuation(final ByteArrayOutputStream buffer, final ByteBuffer payload) throws WebSocketException {
+ if (buffer.size() + payload.remaining() > maxMessageSize) {
+ throw new WebSocketProtocolException(1009, "Message too large: " + (buffer.size() + payload.remaining()));
+ }
+ final ByteBuffer copy = payload.asReadOnlyBuffer();
+ final byte[] data = new byte[copy.remaining()];
+ copy.get(data);
+ buffer.write(data, 0, data.length);
+ }
+
+ private void handleCloseFrame(final WebSocketFrame frame) throws IOException {
+ final ByteBuffer payload = frame.getPayload();
+ final int remaining = payload.remaining();
+ int statusCode = WebSocketCloseStatus.NORMAL.getCode();
+ String reason = "";
+ if (remaining == 1) {
+ throw new WebSocketProtocolException(1002, "Invalid close payload length");
+ } else if (remaining >= 2) {
+ final int code = ((payload.get() & 0xFF) << 8) | (payload.get() & 0xFF);
+ if (!CloseCodec.isValidToReceive(code)) {
+ throw new WebSocketProtocolException(1002, "Invalid close code: " + code);
+ }
+ statusCode = code;
+ if (payload.hasRemaining()) {
+ reason = WebSocketSession.decodeText(payload);
+ }
+ }
+ handler.onClose(session, statusCode, reason);
+ session.close(statusCode, reason);
+ }
+}
diff --git a/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandler.java b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandler.java
new file mode 100644
index 0000000000..4a7adb556f
--- /dev/null
+++ b/httpclient5-websocket/src/main/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandler.java
@@ -0,0 +1,145 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.List;
+import java.util.function.Supplier;
+
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HeaderElements;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpRequestMapper;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.io.HttpServerRequestHandler;
+import org.apache.hc.core5.http.message.BasicClassicHttpResponse;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.websocket.WebSocketCloseStatus;
+import org.apache.hc.core5.websocket.WebSocketConfig;
+import org.apache.hc.core5.websocket.WebSocketConstants;
+import org.apache.hc.core5.websocket.WebSocketException;
+import org.apache.hc.core5.websocket.WebSocketExtensionNegotiation;
+import org.apache.hc.core5.websocket.WebSocketExtensionRegistry;
+import org.apache.hc.core5.websocket.WebSocketExtensions;
+import org.apache.hc.core5.websocket.WebSocketHandler;
+import org.apache.hc.core5.websocket.WebSocketHandshake;
+import org.apache.hc.core5.websocket.WebSocketSession;
+import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException;
+
+class WebSocketServerRequestHandler implements HttpServerRequestHandler {
+
+ private final HttpRequestMapper> requestMapper;
+ private final WebSocketConfig config;
+ private final WebSocketExtensionRegistry extensionRegistry;
+
+ WebSocketServerRequestHandler(
+ final HttpRequestMapper> requestMapper,
+ final WebSocketConfig config,
+ final WebSocketExtensionRegistry extensionRegistry) {
+ this.requestMapper = Args.notNull(requestMapper, "Request mapper");
+ this.config = config != null ? config : WebSocketConfig.DEFAULT;
+ this.extensionRegistry = extensionRegistry != null ? extensionRegistry : new WebSocketExtensionRegistry();
+ }
+
+ @Override
+ public void handle(
+ final ClassicHttpRequest request,
+ final ResponseTrigger trigger,
+ final HttpContext context) throws HttpException, IOException {
+ final Supplier supplier = requestMapper.resolve(request, context);
+ if (supplier == null) {
+ trigger.submitResponse(new BasicClassicHttpResponse(HttpStatus.SC_NOT_FOUND));
+ return;
+ }
+ if (!WebSocketHandshake.isWebSocketUpgrade(request)) {
+ trigger.submitResponse(new BasicClassicHttpResponse(HttpStatus.SC_UPGRADE_REQUIRED));
+ return;
+ }
+ final WebSocketHandler handler = supplier.get();
+ final WebSocketServerConnection connection = getConnection(context);
+ if (connection == null) {
+ trigger.submitResponse(new BasicClassicHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR));
+ return;
+ }
+ final String key = request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_KEY).getValue();
+ final String accept = WebSocketHandshake.createAcceptKey(key);
+ final ClassicHttpResponse response = new BasicClassicHttpResponse(HttpStatus.SC_SWITCHING_PROTOCOLS);
+ response.addHeader(HttpHeaders.CONNECTION, HeaderElements.UPGRADE);
+ response.addHeader(HttpHeaders.UPGRADE, "websocket");
+ response.addHeader(WebSocketConstants.SEC_WEBSOCKET_ACCEPT, accept);
+ final WebSocketExtensionNegotiation negotiation = extensionRegistry.negotiate(
+ WebSocketExtensions.parse(request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS)),
+ true);
+ final String extensionsHeader = negotiation.formatResponseHeader();
+ if (extensionsHeader != null) {
+ response.addHeader(WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS, extensionsHeader);
+ }
+ final List offeredProtocols = WebSocketHandshake.parseSubprotocols(
+ request.getFirstHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL));
+ final String protocol = handler.selectSubprotocol(offeredProtocols);
+ if (protocol != null) {
+ response.addHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL, protocol);
+ }
+ trigger.submitResponse(response);
+ final InputStream inputStream = connection.getSocketInputStream();
+ final OutputStream outputStream = connection.getSocketOutputStream();
+ if (inputStream == null || outputStream == null) {
+ connection.close();
+ return;
+ }
+ final WebSocketSession session = new WebSocketSession(config, inputStream, outputStream,
+ connection.getRemoteAddress(), connection.getLocalAddress(), negotiation.getExtensions());
+ try {
+ handler.onOpen(session);
+ new WebSocketServerProcessor(session, handler, config.getMaxMessageSize()).process();
+ } catch (final WebSocketProtocolException ex) {
+ handler.onError(session, ex);
+ session.close(ex.closeCode, ex.getMessage());
+ } catch (final WebSocketException ex) {
+ handler.onError(session, ex);
+ session.close(WebSocketCloseStatus.PROTOCOL_ERROR.getCode(), ex.getMessage());
+ } catch (final Exception ex) {
+ handler.onError(session, ex);
+ session.close(WebSocketCloseStatus.INTERNAL_ERROR.getCode(), "WebSocket error");
+ } finally {
+ connection.close();
+ }
+ }
+
+ private static WebSocketServerConnection getConnection(final HttpContext context) {
+ if (context == null) {
+ return null;
+ }
+ final Object conn = context.getAttribute(WebSocketContextKeys.CONNECTION);
+ return conn instanceof WebSocketServerConnection ? (WebSocketServerConnection) conn : null;
+ }
+}
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/WebSocketClientBuilderTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilderTest.java
new file mode 100644
index 0000000000..34d8711827
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/WebSocketClientBuilderTest.java
@@ -0,0 +1,173 @@
+/*
+ * ====================================================================
+ * 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.impl.DefaultClientConnectionReuseStrategy;
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.core5.http.HttpConnection;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.HttpResponse;
+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.nio.ssl.BasicClientTlsStrategy;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.pool.ConnPoolListener;
+import org.apache.hc.core5.pool.ConnPoolStats;
+import org.apache.hc.core5.pool.PoolConcurrencyPolicy;
+import org.apache.hc.core5.pool.PoolReusePolicy;
+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;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class WebSocketClientBuilderTest {
+
+ @Test
+ void buildWithCustomSettingsUsesLaxPool() throws Exception {
+ final CloseableWebSocketClient client = WebSocketClientBuilder.create()
+ .defaultConfig(WebSocketClientConfig.custom().enableHttp2(true).build())
+ .setIOReactorConfig(IOReactorConfig.custom().setIoThreadCount(1).build())
+ .setHttp1Config(Http1Config.custom().setMaxHeaderCount(64).build())
+ .setCharCodingConfig(CharCodingConfig.custom().setCharset(java.nio.charset.StandardCharsets.UTF_8).build())
+ .setHttpProcessor(HttpProcessors.client())
+ .setConnectionReuseStrategy(new DefaultClientConnectionReuseStrategy())
+ .setDefaultMaxPerRoute(2)
+ .setMaxTotal(4)
+ .setTimeToLive(Timeout.ofSeconds(2))
+ .setPoolReusePolicy(PoolReusePolicy.FIFO)
+ .setPoolConcurrencyPolicy(PoolConcurrencyPolicy.LAX)
+ .setTlsStrategy(new BasicClientTlsStrategy())
+ .setTlsHandshakeTimeout(Timeout.ofSeconds(3))
+ .setIOSessionDecorator(ioSession -> ioSession)
+ .setExceptionCallback(exception -> { })
+ .setIOSessionListener(new NoopSessionListener())
+ .setStreamListener(new NoopStreamListener())
+ .setConnPoolListener(new NoopConnPoolListener())
+ .setThreadFactory(r -> new Thread(r, "ws-test"))
+ .setReactorMetricsListener(new NoopMetricsListener())
+ .setWorkerSelector(new NoopWorkerSelector())
+ .setMaxPendingCommandsPerConnection(5)
+ .build();
+
+ Assertions.assertNotNull(client);
+ client.close(CloseMode.IMMEDIATE);
+ }
+
+ @Test
+ void buildWithDefaultsUsesStrictPool() throws Exception {
+ final CloseableWebSocketClient client = WebSocketClientBuilder.create().build();
+ Assertions.assertNotNull(client);
+ client.close(CloseMode.IMMEDIATE);
+ }
+
+ private static final class NoopSessionListener implements IOSessionListener {
+ @Override
+ public void connected(final IOSession session) {
+ }
+
+ @Override
+ public void startTls(final IOSession session) {
+ }
+
+ @Override
+ public void inputReady(final IOSession session) {
+ }
+
+ @Override
+ public void outputReady(final IOSession session) {
+ }
+
+ @Override
+ public void timeout(final IOSession session) {
+ }
+
+ @Override
+ public void exception(final IOSession session, final Exception ex) {
+ }
+
+ @Override
+ public void disconnected(final IOSession session) {
+ }
+ }
+
+ private static final class NoopStreamListener implements Http1StreamListener {
+ @Override
+ public void onRequestHead(final HttpConnection connection, final HttpRequest request) {
+ }
+
+ @Override
+ public void onResponseHead(final HttpConnection connection, final HttpResponse response) {
+ }
+
+ @Override
+ public void onExchangeComplete(final HttpConnection connection, final boolean keepAlive) {
+ }
+ }
+
+ private static final class NoopConnPoolListener implements ConnPoolListener {
+ @Override
+ public void onLease(final org.apache.hc.core5.http.HttpHost route,
+ final ConnPoolStats connPoolStats) {
+ }
+
+ @Override
+ public void onRelease(final org.apache.hc.core5.http.HttpHost route,
+ final ConnPoolStats connPoolStats) {
+ }
+ }
+
+ private static final class NoopMetricsListener implements IOReactorMetricsListener {
+ @Override
+ public void onThreadPoolStatus(final int activeThreads, final int pendingConnections) {
+ }
+
+ @Override
+ public void onThreadPoolSaturation(final double saturationPercentage) {
+ }
+
+ @Override
+ public void onResourceStarvationDetected() {
+ }
+
+ @Override
+ public void onQueueWaitTime(final long averageWaitTimeMillis) {
+ }
+ }
+
+ private static final class NoopWorkerSelector implements IOWorkerSelector {
+ @Override
+ public int select(final org.apache.hc.core5.reactor.IOWorkerStats[] dispatchers) {
+ return 0;
+ }
+ }
+}
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/WebSocketClientImplTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/WebSocketClientImplTest.java
new file mode 100644
index 0000000000..5f7e225cb5
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/WebSocketClientImplTest.java
@@ -0,0 +1,435 @@
+/*
+ * ====================================================================
+ * 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.nio.ByteBuffer;
+import java.util.Collections;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+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.WebSocketProtocolStrategy;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.impl.DefaultAddressResolver;
+import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.pool.ManagedConnPool;
+import org.apache.hc.core5.pool.PoolEntry;
+import org.apache.hc.core5.pool.PoolStats;
+import org.apache.hc.core5.reactor.IOEventHandlerFactory;
+import org.apache.hc.core5.reactor.IOReactorConfig;
+import org.apache.hc.core5.reactor.IOSession;
+import org.apache.hc.core5.util.TimeValue;
+import org.apache.hc.core5.util.Timeout;
+import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+@SuppressWarnings({"resource", "try"})
+class WebSocketClientImplTest {
+
+ @Test
+ @SuppressWarnings("resource")
+ void abstractClientStartsAllRequesters() throws Exception {
+ final StubConnPool pool = new StubConnPool();
+ final TestRequester primary = new TestRequester(pool);
+ final TestRequester extra = new TestRequester(pool);
+ final TestClient client = new TestClient(primary, extra);
+ boolean closed = false;
+ try {
+ client.start();
+
+ Assertions.assertTrue(primary.started.await(1, TimeUnit.SECONDS));
+ Assertions.assertTrue(extra.started.await(1, TimeUnit.SECONDS));
+ Assertions.assertTrue(client.isRunning());
+
+ client.initiateShutdown();
+ Assertions.assertEquals(1, primary.shutdownCalls);
+ Assertions.assertEquals(1, extra.shutdownCalls);
+
+ client.close(CloseMode.GRACEFUL);
+ closed = true;
+ Assertions.assertEquals(1, primary.closeCalls);
+ Assertions.assertEquals(1, extra.closeCalls);
+ } finally {
+ if (!closed) {
+ client.close(CloseMode.IMMEDIATE);
+ }
+ }
+ }
+
+ @Test
+ @SuppressWarnings("resource")
+ void internalClientFallsBackToH1OnH2Failure() throws Exception {
+ final StubConnPool pool = new StubConnPool();
+ final TestRequester requester = new TestRequester(pool);
+ final StubProtocol h1 = new StubProtocol();
+ final StubProtocol h2 = new StubProtocol();
+ h2.failWith = new IllegalStateException("h2 fail");
+
+ final TestInternalClient client = new TestInternalClient(requester, pool, h1, h2);
+ boolean closed = false;
+ try {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom().enableHttp2(true).build();
+ final WebSocket ws = client.connect(URI.create("ws://localhost"), new WebSocketListener() { }, cfg, null)
+ .get(1, TimeUnit.SECONDS);
+
+ Assertions.assertNotNull(ws);
+ Assertions.assertEquals(1, h2.calls);
+ Assertions.assertEquals(1, h1.calls);
+ client.close(CloseMode.GRACEFUL);
+ closed = true;
+ } finally {
+ if (!closed) {
+ client.close(CloseMode.IMMEDIATE);
+ }
+ }
+ }
+
+ @Test
+ @SuppressWarnings("resource")
+ void internalClientUsesH2WhenAvailable() throws Exception {
+ final StubConnPool pool = new StubConnPool();
+ final TestRequester requester = new TestRequester(pool);
+ final StubProtocol h1 = new StubProtocol();
+ final StubProtocol h2 = new StubProtocol();
+
+ final TestInternalClient client = new TestInternalClient(requester, pool, h1, h2);
+ boolean closed = false;
+ try {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom().enableHttp2(true).build();
+ final WebSocket ws = client.connect(URI.create("ws://localhost"), new WebSocketListener() { }, cfg, null)
+ .get(1, TimeUnit.SECONDS);
+
+ Assertions.assertNotNull(ws);
+ Assertions.assertEquals(1, h2.calls);
+ Assertions.assertEquals(0, h1.calls);
+ client.close(CloseMode.GRACEFUL);
+ closed = true;
+ } finally {
+ if (!closed) {
+ client.close(CloseMode.IMMEDIATE);
+ }
+ }
+ }
+
+ @Test
+ @SuppressWarnings("resource")
+ void internalClientUsesH1WhenH2Disabled() throws Exception {
+ final StubConnPool pool = new StubConnPool();
+ final TestRequester requester = new TestRequester(pool);
+ final StubProtocol h1 = new StubProtocol();
+ final StubProtocol h2 = new StubProtocol();
+
+ final TestInternalClient client = new TestInternalClient(requester, pool, h1, h2);
+ boolean closed = false;
+ try {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom().enableHttp2(false).build();
+ final WebSocket ws = client.connect(URI.create("ws://localhost"), new WebSocketListener() { }, cfg, null)
+ .get(1, TimeUnit.SECONDS);
+
+ Assertions.assertNotNull(ws);
+ Assertions.assertEquals(0, h2.calls);
+ Assertions.assertEquals(1, h1.calls);
+ client.close(CloseMode.GRACEFUL);
+ closed = true;
+ } finally {
+ if (!closed) {
+ client.close(CloseMode.IMMEDIATE);
+ }
+ }
+ }
+
+ private static final class TestClient extends AbstractWebSocketClient {
+ TestClient(final HttpAsyncRequester requester, final HttpAsyncRequester extra) {
+ super(requester, r -> new Thread(r, "ws-test"), extra);
+ }
+
+ @Override
+ protected CompletableFuture doConnect(
+ final URI uri, final WebSocketListener listener, final WebSocketClientConfig cfg, final HttpContext context) {
+ return CompletableFuture.completedFuture(new StubWebSocket());
+ }
+ }
+
+ private static final class TestInternalClient extends InternalWebSocketClientBase {
+ private final WebSocketProtocolStrategy h1;
+ private final WebSocketProtocolStrategy h2;
+
+ TestInternalClient(final HttpAsyncRequester requester,
+ final ManagedConnPool connPool,
+ final WebSocketProtocolStrategy h1,
+ final WebSocketProtocolStrategy h2) {
+ super(requester, connPool, WebSocketClientConfig.custom().build(), r -> new Thread(r, "ws-test"),
+ new NoopH2Requester());
+ this.h1 = h1;
+ this.h2 = h2;
+ }
+
+ @Override
+ protected WebSocketProtocolStrategy newH1Protocol(
+ final HttpAsyncRequester requester, final ManagedConnPool connPool) {
+ return new DelegatingProtocol(() -> h1);
+ }
+
+ @Override
+ protected WebSocketProtocolStrategy newH2Protocol(final org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester requester) {
+ return new DelegatingProtocol(() -> h2);
+ }
+ }
+
+ private static final class DelegatingProtocol implements WebSocketProtocolStrategy {
+ private final Supplier delegateSupplier;
+
+ private DelegatingProtocol(final Supplier delegateSupplier) {
+ this.delegateSupplier = delegateSupplier;
+ }
+
+ @Override
+ public CompletableFuture connect(
+ final URI uri,
+ final WebSocketListener listener,
+ final WebSocketClientConfig cfg,
+ final HttpContext context) {
+ final WebSocketProtocolStrategy delegate = delegateSupplier.get();
+ if (delegate == null) {
+ final CompletableFuture f = new CompletableFuture<>();
+ f.completeExceptionally(new IllegalStateException("Protocol not configured"));
+ return f;
+ }
+ return delegate.connect(uri, listener, cfg, context);
+ }
+ }
+
+ private static final class StubProtocol implements WebSocketProtocolStrategy {
+ private int calls;
+ private RuntimeException failWith;
+
+ @Override
+ public CompletableFuture connect(final URI uri, final WebSocketListener listener,
+ final WebSocketClientConfig cfg, final HttpContext context) {
+ calls++;
+ if (failWith != null) {
+ final CompletableFuture f = new CompletableFuture<>();
+ f.completeExceptionally(failWith);
+ return f;
+ }
+ return CompletableFuture.completedFuture(new StubWebSocket());
+ }
+ }
+
+ private static final class StubWebSocket implements WebSocket {
+ @Override
+ public boolean isOpen() {
+ return true;
+ }
+
+ @Override
+ public boolean ping(final ByteBuffer data) {
+ return true;
+ }
+
+ @Override
+ public boolean pong(final ByteBuffer data) {
+ return true;
+ }
+
+ @Override
+ public boolean sendText(final CharSequence data, final boolean finalFragment) {
+ return true;
+ }
+
+ @Override
+ public boolean sendBinary(final ByteBuffer data, final boolean finalFragment) {
+ return true;
+ }
+
+ @Override
+ public boolean sendTextBatch(final java.util.List fragments, final boolean finalFragment) {
+ return true;
+ }
+
+ @Override
+ public boolean sendBinaryBatch(final java.util.List fragments, final boolean finalFragment) {
+ return true;
+ }
+
+ @Override
+ public CompletableFuture close(final int statusCode, final String reason) {
+ return CompletableFuture.completedFuture(null);
+ }
+ }
+
+ private static final class NoopH2Requester extends H2MultiplexingRequester {
+ NoopH2Requester() {
+ super(IOReactorConfig.DEFAULT,
+ (ioSession, attachment) -> null,
+ null,
+ null,
+ null,
+ DefaultAddressResolver.INSTANCE,
+ null,
+ null,
+ null,
+ 0);
+ }
+
+ @Override
+ public void start() {
+ }
+
+ @Override
+ public void initiateShutdown() {
+ }
+
+ @Override
+ public void awaitShutdown(final TimeValue waitTime) {
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ }
+ }
+
+ private static final class TestRequester extends HttpAsyncRequester {
+ private final CountDownLatch started = new CountDownLatch(1);
+ private int shutdownCalls;
+ private int closeCalls;
+
+ TestRequester(final ManagedConnPool pool) {
+ super(IOReactorConfig.DEFAULT,
+ (IOEventHandlerFactory) (ioSession, attachment) -> null,
+ (org.apache.hc.core5.function.Decorator) null,
+ (org.apache.hc.core5.function.Callback) null,
+ (org.apache.hc.core5.reactor.IOSessionListener) null,
+ pool,
+ (org.apache.hc.core5.http.nio.ssl.TlsStrategy) null,
+ (Timeout) null,
+ (org.apache.hc.core5.reactor.IOReactorMetricsListener) null,
+ (org.apache.hc.core5.reactor.IOWorkerSelector) null,
+ 0);
+ }
+
+ @Override
+ public void start() {
+ started.countDown();
+ }
+
+ @Override
+ public void initiateShutdown() {
+ shutdownCalls++;
+ }
+
+ @Override
+ public void awaitShutdown(final TimeValue waitTime) {
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ closeCalls++;
+ }
+ }
+
+ private static final class StubConnPool implements ManagedConnPool {
+ @Override
+ public java.util.concurrent.Future> lease(
+ final HttpHost route, final Object state, final Timeout requestTimeout,
+ final FutureCallback> callback) {
+ return CompletableFuture.completedFuture(null);
+ }
+
+ @Override
+ public void release(final PoolEntry entry, final boolean reusable) {
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public void closeIdle(final TimeValue idleTime) {
+ }
+
+ @Override
+ public void closeExpired() {
+ }
+
+ @Override
+ public Set getRoutes() {
+ return Collections.emptySet();
+ }
+
+ @Override
+ public void setMaxTotal(final int max) {
+ }
+
+ @Override
+ public int getMaxTotal() {
+ return 0;
+ }
+
+ @Override
+ public void setDefaultMaxPerRoute(final int max) {
+ }
+
+ @Override
+ public int getDefaultMaxPerRoute() {
+ return 0;
+ }
+
+ @Override
+ public void setMaxPerRoute(final HttpHost route, final int max) {
+ }
+
+ @Override
+ public int getMaxPerRoute(final HttpHost route) {
+ return 0;
+ }
+
+ @Override
+ public PoolStats getTotalStats() {
+ return new PoolStats(0, 0, 0, 0);
+ }
+
+ @Override
+ public PoolStats getStats(final HttpHost route) {
+ return new PoolStats(0, 0, 0, 0);
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnectorTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnectorTest.java
new file mode 100644
index 0000000000..253f6b81c4
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/connector/WebSocketEndpointConnectorTest.java
@@ -0,0 +1,386 @@
+/*
+ * ====================================================================
+ * 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.net.SocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.channels.ByteChannel;
+import java.util.Collections;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncRequester;
+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.pool.PoolStats;
+import org.apache.hc.core5.reactor.Command;
+import org.apache.hc.core5.reactor.IOEventHandler;
+import org.apache.hc.core5.reactor.IOReactorConfig;
+import org.apache.hc.core5.reactor.IOSession;
+import org.apache.hc.core5.util.TimeValue;
+import org.apache.hc.core5.util.Timeout;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class WebSocketEndpointConnectorTest {
+
+ @Test
+ void connectReturnsEndpointWhenPoolEntryHasConnection() throws Exception {
+ final StubConnPool pool = new StubConnPool();
+ final StubSession session = new StubSession(true);
+ final PoolEntry entry = new PoolEntry<>(new HttpHost("http", "localhost", 80));
+ entry.assignConnection(session);
+ pool.entry = entry;
+
+ final TestRequester requester = new TestRequester(pool);
+ final WebSocketEndpointConnector connector = new WebSocketEndpointConnector(requester, pool);
+
+ final Future future =
+ connector.connect(new HttpHost("http", "localhost", 80), Timeout.ofSeconds(1), null, null);
+ final WebSocketEndpointConnector.ProtoEndpoint endpoint = future.get(1, TimeUnit.SECONDS);
+
+ Assertions.assertNotNull(endpoint);
+ Assertions.assertEquals(0, requester.requestSessionCalls);
+ Assertions.assertTrue(endpoint.isConnected());
+ }
+
+ @Test
+ void connectRequestsSessionWhenPoolEntryIsEmpty() throws Exception {
+ final StubConnPool pool = new StubConnPool();
+ pool.entry = new PoolEntry<>(new HttpHost("http", "localhost", 80));
+ final TestRequester requester = new TestRequester(pool);
+ requester.nextSession = new StubSession(true);
+
+ final WebSocketEndpointConnector connector = new WebSocketEndpointConnector(requester, pool);
+ final Timeout timeout = Timeout.ofSeconds(5);
+ final Future future =
+ connector.connect(new HttpHost("http", "localhost", 80), timeout, "attach", null);
+ final WebSocketEndpointConnector.ProtoEndpoint endpoint = future.get(1, TimeUnit.SECONDS);
+
+ Assertions.assertNotNull(endpoint);
+ Assertions.assertEquals(1, requester.requestSessionCalls);
+ Assertions.assertNotNull(pool.entry.getConnection());
+ Assertions.assertEquals(timeout, ((StubSession) pool.entry.getConnection()).socketTimeout);
+ }
+
+ @Test
+ void failedSessionRequestReleasesEntry() throws Exception {
+ final StubConnPool pool = new StubConnPool();
+ pool.entry = new PoolEntry<>(new HttpHost("http", "localhost", 80));
+ final TestRequester requester = new TestRequester(pool);
+ requester.failWith = new IllegalStateException("boom");
+
+ final WebSocketEndpointConnector connector = new WebSocketEndpointConnector(requester, pool);
+ final Future future =
+ connector.connect(new HttpHost("http", "localhost", 80), Timeout.ofSeconds(1), null, null);
+
+ Assertions.assertThrows(Exception.class, () -> future.get(1, TimeUnit.SECONDS));
+ Assertions.assertNotNull(pool.lastReleaseEntry);
+ Assertions.assertFalse(pool.lastReusable);
+ }
+
+ @Test
+ void protoEndpointThrowsWhenNotProtocolSession() {
+ final StubConnPool pool = new StubConnPool();
+ final StubSession session = new StubSession(true);
+ final PoolEntry entry = new PoolEntry<>(new HttpHost("http", "localhost", 80));
+ entry.assignConnection(session);
+ pool.entry = entry;
+
+ final WebSocketEndpointConnector.ProtoEndpoint endpoint =
+ new WebSocketEndpointConnector(new TestRequester(pool), pool).new ProtoEndpoint(entry);
+
+ Assertions.assertThrows(IllegalStateException.class, endpoint::getProtocolIOSession);
+ }
+
+ private static final class TestRequester extends HttpAsyncRequester {
+ private int requestSessionCalls;
+ private IOSession nextSession;
+ private Exception failWith;
+
+ TestRequester(final ManagedConnPool pool) {
+ super(IOReactorConfig.DEFAULT,
+ (ioSession, attachment) -> null,
+ null,
+ null,
+ null,
+ pool,
+ null,
+ null,
+ null,
+ null,
+ 0);
+ }
+
+ @Override
+ public Future requestSession(
+ final HttpHost host,
+ final Timeout timeout,
+ final Object attachment,
+ final FutureCallback callback) {
+ requestSessionCalls++;
+ if (failWith != null) {
+ if (callback != null) {
+ callback.failed(failWith);
+ }
+ final CompletableFuture f = new CompletableFuture<>();
+ f.completeExceptionally(failWith);
+ return f;
+ }
+ final IOSession session = nextSession != null ? nextSession : new StubSession(true);
+ if (callback != null) {
+ callback.completed(session);
+ }
+ return CompletableFuture.completedFuture(session);
+ }
+ }
+
+ private static final class StubConnPool implements ManagedConnPool {
+ private PoolEntry entry;
+ private PoolEntry lastReleaseEntry;
+ private boolean lastReusable;
+
+ @Override
+ public Future> lease(
+ final HttpHost route,
+ final Object state,
+ final Timeout requestTimeout,
+ final FutureCallback> callback) {
+ if (callback != null) {
+ callback.completed(entry);
+ }
+ return CompletableFuture.completedFuture(entry);
+ }
+
+ @Override
+ public void release(final PoolEntry entry, final boolean reusable) {
+ this.lastReleaseEntry = entry;
+ this.lastReusable = reusable;
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public void closeIdle(final TimeValue idleTime) {
+ }
+
+ @Override
+ public void closeExpired() {
+ }
+
+ @Override
+ public Set getRoutes() {
+ return Collections.emptySet();
+ }
+
+ @Override
+ public void setMaxTotal(final int max) {
+ }
+
+ @Override
+ public int getMaxTotal() {
+ return 0;
+ }
+
+ @Override
+ public void setDefaultMaxPerRoute(final int max) {
+ }
+
+ @Override
+ public int getDefaultMaxPerRoute() {
+ return 0;
+ }
+
+ @Override
+ public void setMaxPerRoute(final HttpHost route, final int max) {
+ }
+
+ @Override
+ public int getMaxPerRoute(final HttpHost route) {
+ return 0;
+ }
+
+ @Override
+ public PoolStats getTotalStats() {
+ return new PoolStats(0, 0, 0, 0);
+ }
+
+ @Override
+ public PoolStats getStats(final HttpHost route) {
+ return new PoolStats(0, 0, 0, 0);
+ }
+ }
+
+ private static final class StubSession implements IOSession {
+ private final Lock lock = new ReentrantLock();
+ private final boolean open;
+ private Timeout socketTimeout = Timeout.DISABLED;
+
+ private StubSession(final boolean open) {
+ this.open = open;
+ }
+
+ @Override
+ public int read(final ByteBuffer dst) {
+ return 0;
+ }
+
+ @Override
+ public int write(final ByteBuffer src) {
+ return 0;
+ }
+
+ @Override
+ public boolean isOpen() {
+ return open;
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ }
+
+ @Override
+ public String getId() {
+ return "stub";
+ }
+
+ @Override
+ public IOEventHandler getHandler() {
+ return null;
+ }
+
+ @Override
+ public void upgrade(final IOEventHandler handler) {
+ }
+
+ @Override
+ public Lock getLock() {
+ return lock;
+ }
+
+ @Override
+ public void enqueue(final Command command, final Command.Priority priority) {
+ }
+
+ @Override
+ public boolean hasCommands() {
+ return false;
+ }
+
+ @Override
+ public Command poll() {
+ return null;
+ }
+
+ @Override
+ public ByteChannel channel() {
+ return this;
+ }
+
+ @Override
+ public SocketAddress getRemoteAddress() {
+ return null;
+ }
+
+ @Override
+ public SocketAddress getLocalAddress() {
+ return null;
+ }
+
+ @Override
+ public int getEventMask() {
+ return 0;
+ }
+
+ @Override
+ public void setEventMask(final int ops) {
+ }
+
+ @Override
+ public void setEvent(final int op) {
+ }
+
+ @Override
+ public void clearEvent(final int op) {
+ }
+
+ @Override
+ public Status getStatus() {
+ return open ? Status.ACTIVE : Status.CLOSED;
+ }
+
+ @Override
+ public Timeout getSocketTimeout() {
+ return socketTimeout;
+ }
+
+ @Override
+ public void setSocketTimeout(final Timeout timeout) {
+ socketTimeout = timeout != null ? timeout : Timeout.DISABLED;
+ }
+
+ @Override
+ public long getLastReadTime() {
+ return 0;
+ }
+
+ @Override
+ public long getLastWriteTime() {
+ return 0;
+ }
+
+ @Override
+ public long getLastEventTime() {
+ return 0;
+ }
+
+ @Override
+ public void updateReadTime() {
+ }
+
+ @Override
+ public void updateWriteTime() {
+ }
+ }
+}
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/client/impl/protocol/Http2ExtendedConnectProtocolTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocolTest.java
new file mode 100644
index 0000000000..cc213b3ec5
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/client/impl/protocol/Http2ExtendedConnectProtocolTest.java
@@ -0,0 +1,142 @@
+/*
+ * ====================================================================
+ * 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 java.util.concurrent.ExecutionException;
+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.core5.concurrent.Cancellable;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.impl.DefaultAddressResolver;
+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.RequestChannel;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.http.protocol.HttpCoreContext;
+import org.apache.hc.core5.http2.impl.nio.bootstrap.H2MultiplexingRequester;
+import org.apache.hc.core5.reactor.IOEventHandler;
+import org.apache.hc.core5.reactor.IOEventHandlerFactory;
+import org.apache.hc.core5.reactor.IOReactorConfig;
+import org.apache.hc.core5.reactor.ProtocolIOSession;
+import org.apache.hc.core5.util.Timeout;
+import org.apache.hc.core5.websocket.WebSocketConstants;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class Http2ExtendedConnectProtocolTest {
+
+ @Test
+ void connectFailsWhenRequesterMissing() throws Exception {
+ final Http2ExtendedConnectProtocol protocol = new Http2ExtendedConnectProtocol(null);
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build();
+ final CompletableFuture future =
+ protocol.connect(URI.create("ws://localhost"), new WebSocketListener() { }, cfg, HttpCoreContext.create());
+
+ final ExecutionException ex = Assertions.assertThrows(ExecutionException.class, () -> future.get(1, TimeUnit.SECONDS));
+ Assertions.assertTrue(ex.getCause() instanceof Http2ExtendedConnectProtocol.H2NotAvailable);
+ }
+
+ @Test
+ void connectRejectsInvalidScheme() throws Exception {
+ final RecordingRequester requester = new RecordingRequester();
+ final Http2ExtendedConnectProtocol protocol = new Http2ExtendedConnectProtocol(requester);
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build();
+ final CompletableFuture future =
+ protocol.connect(URI.create("http://localhost"), new WebSocketListener() { }, cfg, HttpCoreContext.create());
+
+ final ExecutionException ex = Assertions.assertThrows(ExecutionException.class, () -> future.get(1, TimeUnit.SECONDS));
+ Assertions.assertTrue(ex.getCause() instanceof IllegalArgumentException);
+ Assertions.assertNull(requester.requestRef.get());
+ }
+
+ @Test
+ void connectBuildsRequestWithProtocolHeader() {
+ final RecordingRequester requester = new RecordingRequester();
+ final Http2ExtendedConnectProtocol protocol = new Http2ExtendedConnectProtocol(requester);
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build();
+
+ protocol.connect(URI.create("ws://example.com"), new WebSocketListener() { }, cfg, HttpCoreContext.create());
+
+ final HttpRequest request = requester.requestRef.get();
+ Assertions.assertNotNull(request);
+ Assertions.assertEquals("CONNECT", request.getMethod());
+ Assertions.assertEquals("/", request.getPath());
+ Assertions.assertEquals("websocket", request.getFirstHeader(WebSocketConstants.PSEUDO_PROTOCOL).getValue());
+ }
+
+ private static final class RecordingRequester extends H2MultiplexingRequester {
+ private final AtomicReference requestRef = new AtomicReference<>();
+
+ RecordingRequester() {
+ super(IOReactorConfig.DEFAULT,
+ new IOEventHandlerFactory() {
+ @Override
+ public IOEventHandler createHandler(final ProtocolIOSession ioSession, final Object attachment) {
+ return null;
+ }
+ },
+ null,
+ null,
+ null,
+ DefaultAddressResolver.INSTANCE,
+ null,
+ null,
+ null,
+ 0);
+ }
+
+ @Override
+ public Cancellable execute(
+ final HttpHost target,
+ final AsyncClientExchangeHandler exchangeHandler,
+ final HandlerFactory pushHandlerFactory,
+ final Timeout timeout,
+ final HttpContext context) {
+ final HttpContext ctx = context != null ? context : HttpCoreContext.create();
+ try {
+ exchangeHandler.produceRequest(new RequestChannel() {
+ @Override
+ public void sendRequest(final HttpRequest request, final org.apache.hc.core5.http.EntityDetails entityDetails,
+ final HttpContext context) {
+ requestRef.set(request);
+ }
+ }, ctx);
+ } catch (final Exception ex) {
+ throw new RuntimeException(ex);
+ }
+ return () -> true;
+ }
+ }
+}
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..412b5a19f5
--- /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 httpclient5-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..58595fbe4f
--- /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);
+ Runtime.getRuntime().addShutdownHook(new Thread(done::countDown));
+
+ 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) {
+ // Keep server running for additional sessions.
+ }
+
+ @Override
+ public void onError(final WebSocketSession session, final Exception cause) {
+ cause.printStackTrace(System.err);
+ }
+ })
+ .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..30dfafba78
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/example/WebSocketH2TlsEchoServer.java
@@ -0,0 +1,118 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.websocket.example;
+
+import java.io.IOException;
+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);
+ Runtime.getRuntime().addShutdownHook(new Thread(done::countDown));
+
+ 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 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) {
+ // Keep server running for additional sessions.
+ }
+
+ @Override
+ public void onError(final WebSocketSession session, final Exception cause) {
+ cause.printStackTrace(System.err);
+ }
+ })
+ .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/WebSocketInboundRfcTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketInboundRfcTest.java
new file mode 100644
index 0000000000..633bd46fb8
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketInboundRfcTest.java
@@ -0,0 +1,312 @@
+/*
+ * ====================================================================
+ * 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 java.net.SocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.channels.ByteChannel;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import javax.net.ssl.SSLContext;
+
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.net.NamedEndpoint;
+import org.apache.hc.core5.reactor.Command;
+import org.apache.hc.core5.reactor.IOEventHandler;
+import org.apache.hc.core5.reactor.ProtocolIOSession;
+import org.apache.hc.core5.reactor.ProtocolUpgradeHandler;
+import org.apache.hc.core5.reactor.ssl.SSLBufferMode;
+import org.apache.hc.core5.reactor.ssl.SSLSessionInitializer;
+import org.apache.hc.core5.reactor.ssl.SSLSessionVerifier;
+import org.apache.hc.core5.reactor.ssl.TlsDetails;
+import org.apache.hc.core5.util.Timeout;
+import org.apache.hc.core5.websocket.frame.FrameOpcode;
+import org.junit.jupiter.api.Test;
+
+class WebSocketInboundRfcTest {
+
+ @Test
+ void closeReasonInvalidUtf8_closesWith1007() {
+ final CloseCaptureListener listener = new CloseCaptureListener();
+ final WebSocketInbound inbound = newInbound(listener);
+ final ProtocolIOSession session = new StubSession();
+
+ final byte[] payload = new byte[]{0x03, (byte) 0xE8, (byte) 0xC3, (byte) 0x28}; // 1000 + invalid UTF-8
+ inbound.onInputReady(session, unmaskedFrame(FrameOpcode.CLOSE, true, payload));
+
+ assertEquals(1007, listener.closeCode.get());
+ }
+
+ @Test
+ void closeCodeInvalidOnWire_closesWith1002() {
+ final CloseCaptureListener listener = new CloseCaptureListener();
+ final WebSocketInbound inbound = newInbound(listener);
+ final ProtocolIOSession session = new StubSession();
+
+ final byte[] payload = new byte[]{0x03, (byte) 0xED}; // 1005 (invalid on wire)
+ inbound.onInputReady(session, unmaskedFrame(FrameOpcode.CLOSE, true, payload));
+
+ assertEquals(1002, listener.closeCode.get());
+ }
+
+ @Test
+ void dataFrameWhileFragmentedMessage_closesWith1002() {
+ final CloseCaptureListener listener = new CloseCaptureListener();
+ final WebSocketInbound inbound = newInbound(listener);
+ final ProtocolIOSession session = new StubSession();
+
+ inbound.onInputReady(session, unmaskedFrame(FrameOpcode.TEXT, false, new byte[]{0x61}));
+ inbound.onInputReady(session, unmaskedFrame(FrameOpcode.BINARY, true, new byte[]{0x62}));
+
+ assertEquals(1002, listener.closeCode.get());
+ }
+
+ @Test
+ void unexpectedContinuation_closesWith1002() {
+ final CloseCaptureListener listener = new CloseCaptureListener();
+ final WebSocketInbound inbound = newInbound(listener);
+ final ProtocolIOSession session = new StubSession();
+
+ inbound.onInputReady(session, unmaskedFrame(FrameOpcode.CONT, true, new byte[]{0x01}));
+
+ assertEquals(1002, listener.closeCode.get());
+ }
+
+ @Test
+ void fragmentedControlFrame_closesWith1002() {
+ final CloseCaptureListener listener = new CloseCaptureListener();
+ final WebSocketInbound inbound = newInbound(listener);
+ final ProtocolIOSession session = new StubSession();
+
+ inbound.onInputReady(session, unmaskedFrame(FrameOpcode.PING, false, new byte[0]));
+
+ assertEquals(1002, listener.closeCode.get());
+ }
+
+ private static WebSocketInbound newInbound(final CloseCaptureListener listener) {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .enablePerMessageDeflate(false)
+ .build();
+ final WebSocketSessionState state = new WebSocketSessionState(
+ new StubSession(),
+ listener,
+ cfg,
+ null);
+ final WebSocketOutbound outbound = new WebSocketOutbound(state);
+ return new WebSocketInbound(state, outbound);
+ }
+
+ private static ByteBuffer unmaskedFrame(final int opcode, final boolean fin, final byte[] payload) {
+ final int len = payload != null ? payload.length : 0;
+ final ByteBuffer buf = ByteBuffer.allocate(2 + len);
+ buf.put((byte) ((fin ? 0x80 : 0x00) | (opcode & 0x0F)));
+ buf.put((byte) len);
+ if (len > 0) {
+ buf.put(payload);
+ }
+ buf.flip();
+ return buf;
+ }
+
+ private static final class CloseCaptureListener implements WebSocketListener {
+ private final AtomicInteger closeCode = new AtomicInteger(-1);
+
+ @Override
+ public void onClose(final int code, final String reason) {
+ closeCode.compareAndSet(-1, code);
+ }
+ }
+
+ private static final class StubSession implements ProtocolIOSession {
+ private final Lock lock = new ReentrantLock();
+ private volatile Timeout socketTimeout = Timeout.DISABLED;
+ private volatile Status status = Status.ACTIVE;
+
+ @Override
+ public int read(final ByteBuffer dst) {
+ return 0;
+ }
+
+ @Override
+ public int write(final ByteBuffer src) {
+ return 0;
+ }
+
+ @Override
+ public boolean isOpen() {
+ return status == Status.ACTIVE;
+ }
+
+ @Override
+ public void close() {
+ status = Status.CLOSED;
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ status = Status.CLOSED;
+ }
+
+ @Override
+ public String getId() {
+ return "stub";
+ }
+
+ @Override
+ public IOEventHandler getHandler() {
+ return null;
+ }
+
+ @Override
+ public void upgrade(final IOEventHandler handler) {
+ }
+
+ @Override
+ public Lock getLock() {
+ return lock;
+ }
+
+ @Override
+ public void enqueue(final Command command, final Command.Priority priority) {
+ }
+
+ @Override
+ public boolean hasCommands() {
+ return false;
+ }
+
+ @Override
+ public Command poll() {
+ return null;
+ }
+
+ @Override
+ public ByteChannel channel() {
+ return this;
+ }
+
+ @Override
+ public SocketAddress getRemoteAddress() {
+ return null;
+ }
+
+ @Override
+ public SocketAddress getLocalAddress() {
+ return null;
+ }
+
+ @Override
+ public int getEventMask() {
+ return 0;
+ }
+
+ @Override
+ public void setEventMask(final int ops) {
+ }
+
+ @Override
+ public void setEvent(final int op) {
+ }
+
+ @Override
+ public void clearEvent(final int op) {
+ }
+
+ @Override
+ public Status getStatus() {
+ return status;
+ }
+
+ @Override
+ public Timeout getSocketTimeout() {
+ return socketTimeout;
+ }
+
+ @Override
+ public void setSocketTimeout(final Timeout timeout) {
+ socketTimeout = timeout != null ? timeout : Timeout.DISABLED;
+ }
+
+ @Override
+ public long getLastReadTime() {
+ return 0;
+ }
+
+ @Override
+ public long getLastWriteTime() {
+ return 0;
+ }
+
+ @Override
+ public long getLastEventTime() {
+ return 0;
+ }
+
+ @Override
+ public void updateReadTime() {
+ }
+
+ @Override
+ public void updateWriteTime() {
+ }
+
+ @Override
+ public void startTls(final SSLContext sslContext, final NamedEndpoint endpoint, final SSLBufferMode sslBufferMode,
+ final SSLSessionInitializer initializer, final SSLSessionVerifier verifier,
+ final Timeout handshakeTimeout) throws UnsupportedOperationException {
+ }
+
+ @Override
+ public TlsDetails getTlsDetails() {
+ return null;
+ }
+
+ @Override
+ public NamedEndpoint getInitialEndpoint() {
+ return null;
+ }
+
+ @Override
+ public void registerProtocol(final String protocolId, final ProtocolUpgradeHandler upgradeHandler) {
+ }
+
+ @Override
+ public void switchProtocol(final String protocolId, final FutureCallback callback)
+ throws UnsupportedOperationException {
+ if (callback != null) {
+ callback.failed(new UnsupportedOperationException());
+ }
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandlerTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandlerTest.java
new file mode 100644
index 0000000000..198da2c26c
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketIoHandlerTest.java
@@ -0,0 +1,322 @@
+/*
+ * ====================================================================
+ * 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.channels.ByteChannel;
+import java.net.SocketAddress;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import javax.net.ssl.SSLContext;
+
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.core5.concurrent.FutureCallback;
+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.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.net.NamedEndpoint;
+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.reactor.ProtocolUpgradeHandler;
+import org.apache.hc.core5.reactor.ssl.SSLBufferMode;
+import org.apache.hc.core5.reactor.ssl.SSLSessionInitializer;
+import org.apache.hc.core5.reactor.ssl.SSLSessionVerifier;
+import org.apache.hc.core5.reactor.ssl.TlsDetails;
+import org.apache.hc.core5.util.Timeout;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class WebSocketIoHandlerTest {
+
+ @Test
+ void connectedSetsTimeoutAndEvents() {
+ final StubSession session = new StubSession();
+ final WebSocketIoHandler handler = new WebSocketIoHandler(
+ session, new CapturingListener(), WebSocketClientConfig.custom().build(), null, null);
+
+ handler.connected(session);
+
+ Assertions.assertEquals(Timeout.DISABLED, session.socketTimeout);
+ Assertions.assertEquals(EventMask.READ | EventMask.WRITE, session.eventMask);
+ }
+
+ @Test
+ void timeoutClosesAndNotifies() {
+ final StubSession session = new StubSession();
+ final CapturingListener listener = new CapturingListener();
+ final WebSocketIoHandler handler = new WebSocketIoHandler(
+ session, listener, WebSocketClientConfig.custom().build(), null, null);
+
+ handler.timeout(session, Timeout.ofSeconds(1));
+
+ Assertions.assertTrue(listener.error.get() instanceof java.util.concurrent.TimeoutException);
+ Assertions.assertEquals(CloseMode.GRACEFUL, session.closedWith);
+ }
+
+ @Test
+ void exceptionClosesAndNotifies() {
+ final StubSession session = new StubSession();
+ final CapturingListener listener = new CapturingListener();
+ final WebSocketIoHandler handler = new WebSocketIoHandler(
+ session, listener, WebSocketClientConfig.custom().build(), null, null);
+
+ final IllegalStateException ex = new IllegalStateException("boom");
+ handler.exception(session, ex);
+
+ Assertions.assertSame(ex, listener.error.get());
+ Assertions.assertEquals(CloseMode.GRACEFUL, session.closedWith);
+ }
+
+ @Test
+ void disconnectedEnqueuesShutdownAndReleasesEndpoint() {
+ final StubSession session = new StubSession();
+ final StubEndpoint endpoint = new StubEndpoint();
+ final WebSocketIoHandler handler = new WebSocketIoHandler(
+ session, new CapturingListener(), WebSocketClientConfig.custom().build(), null, endpoint);
+
+ handler.disconnected(session);
+
+ Assertions.assertNotNull(session.lastCommand);
+ Assertions.assertTrue(session.lastCommand instanceof org.apache.hc.core5.http.nio.command.ShutdownCommand);
+ Assertions.assertEquals(Command.Priority.IMMEDIATE, session.lastPriority);
+ Assertions.assertTrue(endpoint.released);
+ }
+
+ private static final class CapturingListener implements WebSocketListener {
+ private final AtomicReference error = new AtomicReference<>();
+
+ @Override
+ public void onError(final Throwable cause) {
+ error.compareAndSet(null, cause);
+ }
+ }
+
+ private static final class StubEndpoint extends AsyncClientEndpoint {
+ private volatile boolean released;
+
+ @Override
+ public void execute(final AsyncClientExchangeHandler exchangeHandler,
+ final HandlerFactory pushHandlerFactory,
+ final HttpContext context) {
+ }
+
+ @Override
+ public void releaseAndReuse() {
+ }
+
+ @Override
+ public void releaseAndDiscard() {
+ released = true;
+ }
+
+ @Override
+ public boolean isConnected() {
+ return true;
+ }
+ }
+
+ private static final class StubSession implements ProtocolIOSession {
+ private final Lock lock = new ReentrantLock();
+ private volatile Timeout socketTimeout = Timeout.DISABLED;
+ private volatile IOSession.Status status = IOSession.Status.ACTIVE;
+ private volatile int eventMask;
+ private volatile CloseMode closedWith;
+ private volatile Command lastCommand;
+ private volatile Command.Priority lastPriority;
+
+ @Override
+ public int read(final ByteBuffer dst) {
+ return 0;
+ }
+
+ @Override
+ public int write(final ByteBuffer src) {
+ return 0;
+ }
+
+ @Override
+ public boolean isOpen() {
+ return status == IOSession.Status.ACTIVE;
+ }
+
+ @Override
+ public void close() {
+ status = IOSession.Status.CLOSED;
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ status = IOSession.Status.CLOSED;
+ closedWith = closeMode;
+ }
+
+ @Override
+ public String getId() {
+ return "stub";
+ }
+
+ @Override
+ public IOEventHandler getHandler() {
+ return null;
+ }
+
+ @Override
+ public void upgrade(final IOEventHandler handler) {
+ }
+
+ @Override
+ public Lock getLock() {
+ return lock;
+ }
+
+ @Override
+ public void enqueue(final Command command, final Command.Priority priority) {
+ lastCommand = command;
+ lastPriority = priority;
+ }
+
+ @Override
+ public boolean hasCommands() {
+ return false;
+ }
+
+ @Override
+ public Command poll() {
+ return null;
+ }
+
+ @Override
+ public ByteChannel channel() {
+ return this;
+ }
+
+ @Override
+ public SocketAddress getRemoteAddress() {
+ return null;
+ }
+
+ @Override
+ public SocketAddress getLocalAddress() {
+ return null;
+ }
+
+ @Override
+ public int getEventMask() {
+ return eventMask;
+ }
+
+ @Override
+ public void setEventMask(final int ops) {
+ eventMask = ops;
+ }
+
+ @Override
+ public void setEvent(final int op) {
+ eventMask |= op;
+ }
+
+ @Override
+ public void clearEvent(final int op) {
+ eventMask &= ~op;
+ }
+
+ @Override
+ public IOSession.Status getStatus() {
+ return status;
+ }
+
+ @Override
+ public Timeout getSocketTimeout() {
+ return socketTimeout;
+ }
+
+ @Override
+ public void setSocketTimeout(final Timeout timeout) {
+ socketTimeout = timeout != null ? timeout : Timeout.DISABLED;
+ }
+
+ @Override
+ public long getLastReadTime() {
+ return 0;
+ }
+
+ @Override
+ public long getLastWriteTime() {
+ return 0;
+ }
+
+ @Override
+ public long getLastEventTime() {
+ return 0;
+ }
+
+ @Override
+ public void updateReadTime() {
+ }
+
+ @Override
+ public void updateWriteTime() {
+ }
+
+ @Override
+ public void startTls(final SSLContext sslContext, final NamedEndpoint endpoint, final SSLBufferMode sslBufferMode,
+ final SSLSessionInitializer initializer, final SSLSessionVerifier verifier,
+ final Timeout handshakeTimeout) throws UnsupportedOperationException {
+ }
+
+ @Override
+ public TlsDetails getTlsDetails() {
+ return null;
+ }
+
+ @Override
+ public NamedEndpoint getInitialEndpoint() {
+ return null;
+ }
+
+ @Override
+ public void registerProtocol(final String protocolId, final ProtocolUpgradeHandler upgradeHandler) {
+ }
+
+ @Override
+ public void switchProtocol(final String protocolId, final FutureCallback callback)
+ throws UnsupportedOperationException {
+ if (callback != null) {
+ callback.failed(new UnsupportedOperationException());
+ }
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgraderTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgraderTest.java
new file mode 100644
index 0000000000..0e934d11de
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WebSocketUpgraderTest.java
@@ -0,0 +1,271 @@
+/*
+ * ====================================================================
+ * 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.channels.ByteChannel;
+import java.net.SocketAddress;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import javax.net.ssl.SSLContext;
+
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.net.NamedEndpoint;
+import org.apache.hc.core5.reactor.Command;
+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.reactor.ProtocolUpgradeHandler;
+import org.apache.hc.core5.reactor.ssl.SSLBufferMode;
+import org.apache.hc.core5.reactor.ssl.SSLSessionInitializer;
+import org.apache.hc.core5.reactor.ssl.SSLSessionVerifier;
+import org.apache.hc.core5.reactor.ssl.TlsDetails;
+import org.apache.hc.core5.util.Timeout;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class WebSocketUpgraderTest {
+
+ @Test
+ void upgradeInstallsHandlerAndExposesWebSocket() {
+ final StubSession session = new StubSession(false);
+ final WebSocketUpgrader upgrader = new WebSocketUpgrader(
+ new WebSocketListener() { }, WebSocketClientConfig.custom().build(), null);
+ final CapturingCallback callback = new CapturingCallback();
+
+ upgrader.upgrade(session, callback);
+
+ Assertions.assertNotNull(session.lastHandler);
+ Assertions.assertNotNull(upgrader.getWebSocket());
+ Assertions.assertSame(session, callback.completed.get());
+ Assertions.assertNull(callback.failed.get());
+ }
+
+ @Test
+ void upgradeFailureReportsCallback() {
+ final StubSession session = new StubSession(true);
+ final WebSocketUpgrader upgrader = new WebSocketUpgrader(
+ new WebSocketListener() { }, WebSocketClientConfig.custom().build(), null);
+ final CapturingCallback callback = new CapturingCallback();
+
+ upgrader.upgrade(session, callback);
+
+ Assertions.assertNull(callback.completed.get());
+ Assertions.assertNotNull(callback.failed.get());
+ }
+
+ private static final class CapturingCallback implements FutureCallback {
+ private final AtomicReference completed = new AtomicReference<>();
+ private final AtomicReference failed = new AtomicReference<>();
+
+ @Override
+ public void completed(final ProtocolIOSession result) {
+ completed.compareAndSet(null, result);
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ failed.compareAndSet(null, ex);
+ }
+
+ @Override
+ public void cancelled() {
+ }
+ }
+
+ private static final class StubSession implements ProtocolIOSession {
+ private final Lock lock = new ReentrantLock();
+ private final boolean failUpgrade;
+ private volatile IOEventHandler lastHandler;
+
+ private StubSession(final boolean failUpgrade) {
+ this.failUpgrade = failUpgrade;
+ }
+
+ @Override
+ public int read(final ByteBuffer dst) {
+ return 0;
+ }
+
+ @Override
+ public int write(final ByteBuffer src) {
+ return 0;
+ }
+
+ @Override
+ public boolean isOpen() {
+ return true;
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ }
+
+ @Override
+ public String getId() {
+ return "stub";
+ }
+
+ @Override
+ public IOEventHandler getHandler() {
+ return null;
+ }
+
+ @Override
+ public void upgrade(final IOEventHandler handler) {
+ if (failUpgrade) {
+ throw new IllegalStateException("upgrade failed");
+ }
+ lastHandler = handler;
+ }
+
+ @Override
+ public Lock getLock() {
+ return lock;
+ }
+
+ @Override
+ public void enqueue(final Command command, final Command.Priority priority) {
+ }
+
+ @Override
+ public boolean hasCommands() {
+ return false;
+ }
+
+ @Override
+ public Command poll() {
+ return null;
+ }
+
+ @Override
+ public ByteChannel channel() {
+ return this;
+ }
+
+ @Override
+ public SocketAddress getRemoteAddress() {
+ return null;
+ }
+
+ @Override
+ public SocketAddress getLocalAddress() {
+ return null;
+ }
+
+ @Override
+ public int getEventMask() {
+ return 0;
+ }
+
+ @Override
+ public void setEventMask(final int ops) {
+ }
+
+ @Override
+ public void setEvent(final int op) {
+ }
+
+ @Override
+ public void clearEvent(final int op) {
+ }
+
+ @Override
+ public IOSession.Status getStatus() {
+ return IOSession.Status.ACTIVE;
+ }
+
+ @Override
+ public Timeout getSocketTimeout() {
+ return Timeout.DISABLED;
+ }
+
+ @Override
+ public void setSocketTimeout(final Timeout timeout) {
+ }
+
+ @Override
+ public long getLastReadTime() {
+ return 0;
+ }
+
+ @Override
+ public long getLastWriteTime() {
+ return 0;
+ }
+
+ @Override
+ public long getLastEventTime() {
+ return 0;
+ }
+
+ @Override
+ public void updateReadTime() {
+ }
+
+ @Override
+ public void updateWriteTime() {
+ }
+
+ @Override
+ public void startTls(final SSLContext sslContext, final NamedEndpoint endpoint, final SSLBufferMode sslBufferMode,
+ final SSLSessionInitializer initializer, final SSLSessionVerifier verifier,
+ final Timeout handshakeTimeout) throws UnsupportedOperationException {
+ }
+
+ @Override
+ public TlsDetails getTlsDetails() {
+ return null;
+ }
+
+ @Override
+ public NamedEndpoint getInitialEndpoint() {
+ return null;
+ }
+
+ @Override
+ public void registerProtocol(final String protocolId, final ProtocolUpgradeHandler upgradeHandler) {
+ }
+
+ @Override
+ public void switchProtocol(final String protocolId, final FutureCallback callback)
+ throws UnsupportedOperationException {
+ if (callback != null) {
+ callback.failed(new UnsupportedOperationException());
+ }
+ }
+ }
+}
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..da96fbfaa6
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsDecoderTest.java
@@ -0,0 +1,173 @@
+/*
+ * ====================================================================
+ * 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 clientMaskedFrame_isAccepted_whenExpectMasked() {
+ final ByteBuffer buf = ByteBuffer.allocate(2 + 4);
+ buf.put((byte) 0x81);
+ buf.put((byte) 0x80); // MASK set, len=0
+ buf.putInt(0x01020304);
+ buf.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192, true, true);
+ assertTrue(d.decode(buf));
+ assertEquals(FrameOpcode.TEXT, d.opcode());
+ }
+
+ @Test
+ void clientUnmaskedFrame_isRejected_whenExpectMasked() {
+ final ByteBuffer buf = ByteBuffer.allocate(2);
+ buf.put((byte) 0x81);
+ buf.put((byte) 0x00); // no MASK
+ buf.flip();
+
+ final WebSocketFrameDecoder d = new WebSocketFrameDecoder(8192, true, true);
+ 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/WsInboundBufferTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsInboundBufferTest.java
new file mode 100644
index 0000000000..24c012aef4
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsInboundBufferTest.java
@@ -0,0 +1,244 @@
+/*
+ * ====================================================================
+ * 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.channels.ByteChannel;
+import java.net.SocketAddress;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import javax.net.ssl.SSLContext;
+
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.net.NamedEndpoint;
+import org.apache.hc.core5.reactor.Command;
+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.reactor.ProtocolUpgradeHandler;
+import org.apache.hc.core5.reactor.ssl.SSLBufferMode;
+import org.apache.hc.core5.reactor.ssl.SSLSessionInitializer;
+import org.apache.hc.core5.reactor.ssl.SSLSessionVerifier;
+import org.apache.hc.core5.reactor.ssl.TlsDetails;
+import org.apache.hc.core5.util.Timeout;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class WsInboundBufferTest {
+
+ @Test
+ void readBufferIsRecreatedWhenNull() {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build();
+ final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), new WebSocketListener() { }, cfg, null);
+ final WebSocketOutbound outbound = new WebSocketOutbound(state);
+ final WebSocketInbound inbound = new WebSocketInbound(state, outbound);
+
+ state.readBuf = null;
+ inbound.onInputReady(state.session, ByteBuffer.allocate(0));
+
+ Assertions.assertNotNull(state.readBuf);
+ }
+
+ @Test
+ void onDisconnectedDoesNotClearReadBuffer() {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build();
+ final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), new WebSocketListener() { }, cfg, null);
+ final WebSocketOutbound outbound = new WebSocketOutbound(state);
+ final WebSocketInbound inbound = new WebSocketInbound(state, outbound);
+
+ final ByteBuffer initial = state.readBuf;
+ inbound.onDisconnected(state.session);
+
+ Assertions.assertSame(initial, state.readBuf);
+ }
+
+ private static final class StubSession implements ProtocolIOSession {
+ private final Lock lock = new ReentrantLock();
+ private volatile Timeout socketTimeout = Timeout.DISABLED;
+ private volatile IOSession.Status status = IOSession.Status.ACTIVE;
+
+ @Override
+ public int read(final ByteBuffer dst) {
+ return 0;
+ }
+
+ @Override
+ public int write(final ByteBuffer src) {
+ return 0;
+ }
+
+ @Override
+ public boolean isOpen() {
+ return status == IOSession.Status.ACTIVE;
+ }
+
+ @Override
+ public void close() {
+ status = IOSession.Status.CLOSED;
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ status = IOSession.Status.CLOSED;
+ }
+
+ @Override
+ public String getId() {
+ return "stub";
+ }
+
+ @Override
+ public IOEventHandler getHandler() {
+ return null;
+ }
+
+ @Override
+ public void upgrade(final IOEventHandler handler) {
+ }
+
+ @Override
+ public Lock getLock() {
+ return lock;
+ }
+
+ @Override
+ public void enqueue(final Command command, final Command.Priority priority) {
+ }
+
+ @Override
+ public boolean hasCommands() {
+ return false;
+ }
+
+ @Override
+ public Command poll() {
+ return null;
+ }
+
+ @Override
+ public ByteChannel channel() {
+ return this;
+ }
+
+ @Override
+ public SocketAddress getRemoteAddress() {
+ return null;
+ }
+
+ @Override
+ public SocketAddress getLocalAddress() {
+ return null;
+ }
+
+ @Override
+ public int getEventMask() {
+ return 0;
+ }
+
+ @Override
+ public void setEventMask(final int ops) {
+ }
+
+ @Override
+ public void setEvent(final int op) {
+ }
+
+ @Override
+ public void clearEvent(final int op) {
+ }
+
+ @Override
+ public IOSession.Status getStatus() {
+ return status;
+ }
+
+ @Override
+ public Timeout getSocketTimeout() {
+ return socketTimeout;
+ }
+
+ @Override
+ public void setSocketTimeout(final Timeout timeout) {
+ socketTimeout = timeout != null ? timeout : Timeout.DISABLED;
+ }
+
+ @Override
+ public long getLastReadTime() {
+ return 0;
+ }
+
+ @Override
+ public long getLastWriteTime() {
+ return 0;
+ }
+
+ @Override
+ public long getLastEventTime() {
+ return 0;
+ }
+
+ @Override
+ public void updateReadTime() {
+ }
+
+ @Override
+ public void updateWriteTime() {
+ }
+
+ @Override
+ public void startTls(final SSLContext sslContext, final NamedEndpoint endpoint, final SSLBufferMode sslBufferMode,
+ final SSLSessionInitializer initializer, final SSLSessionVerifier verifier,
+ final Timeout handshakeTimeout) throws UnsupportedOperationException {
+ }
+
+ @Override
+ public TlsDetails getTlsDetails() {
+ return null;
+ }
+
+ @Override
+ public NamedEndpoint getInitialEndpoint() {
+ return null;
+ }
+
+ @Override
+ public void registerProtocol(final String protocolId, final ProtocolUpgradeHandler upgradeHandler) {
+ }
+
+ @Override
+ public void switchProtocol(final String protocolId, final FutureCallback callback)
+ throws UnsupportedOperationException {
+ if (callback != null) {
+ callback.failed(new UnsupportedOperationException());
+ }
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsInboundCoverageTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsInboundCoverageTest.java
new file mode 100644
index 0000000000..6e2a7305fd
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsInboundCoverageTest.java
@@ -0,0 +1,339 @@
+/*
+ * ====================================================================
+ * 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.CharBuffer;
+import java.nio.channels.ByteChannel;
+import java.net.SocketAddress;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import javax.net.ssl.SSLContext;
+
+import org.apache.hc.client5.http.websocket.api.WebSocketClientConfig;
+import org.apache.hc.client5.http.websocket.api.WebSocketListener;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.net.NamedEndpoint;
+import org.apache.hc.core5.reactor.Command;
+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.reactor.ProtocolUpgradeHandler;
+import org.apache.hc.core5.reactor.ssl.SSLBufferMode;
+import org.apache.hc.core5.reactor.ssl.SSLSessionInitializer;
+import org.apache.hc.core5.reactor.ssl.SSLSessionVerifier;
+import org.apache.hc.core5.reactor.ssl.TlsDetails;
+import org.apache.hc.core5.util.Timeout;
+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.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class WsInboundCoverageTest {
+
+ @Test
+ void autoPongQueuesPong() {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom().build();
+ final CapturingListener listener = new CapturingListener();
+ final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), listener, cfg, null);
+ final WebSocketOutbound outbound = new WebSocketOutbound(state);
+ final WebSocketInbound inbound = new WebSocketInbound(state, outbound);
+
+ final WebSocketFrameWriter writer = new WebSocketFrameWriter();
+ final ByteBuffer ping = writer.frame(FrameOpcode.PING, ByteBuffer.wrap(new byte[] { 1 }), true, false);
+ inbound.onInputReady(state.session, ping);
+
+ Assertions.assertEquals(1, listener.pingCount.get());
+ Assertions.assertFalse(state.ctrlOutbound.isEmpty());
+ }
+
+ @Test
+ void fragmentedControlFrameTriggersClose() {
+ final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), new CapturingListener(),
+ WebSocketClientConfig.custom().build(), null);
+ final WebSocketOutbound outbound = new WebSocketOutbound(state);
+ final WebSocketInbound inbound = new WebSocketInbound(state, outbound);
+
+ final ByteBuffer badPing = ByteBuffer.allocate(2);
+ badPing.put((byte) FrameOpcode.PING);
+ badPing.put((byte) 0);
+ badPing.flip();
+
+ inbound.onInputReady(state.session, badPing);
+
+ Assertions.assertTrue(state.closeSent.get());
+ }
+
+ @Test
+ void continuationWithoutStartTriggersClose() {
+ final CapturingListener listener = new CapturingListener();
+ final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), listener,
+ WebSocketClientConfig.custom().build(), null);
+ final WebSocketOutbound outbound = new WebSocketOutbound(state);
+ final WebSocketInbound inbound = new WebSocketInbound(state, outbound);
+
+ final WebSocketFrameWriter writer = new WebSocketFrameWriter();
+ final ByteBuffer cont = writer.frame(FrameOpcode.CONT, ByteBuffer.allocate(0), true, false);
+ inbound.onInputReady(state.session, cont);
+
+ Assertions.assertEquals(1002, listener.closeCode.get());
+ }
+
+ @Test
+ void closeFrameLengthOneIsRejected() {
+ final CapturingListener listener = new CapturingListener();
+ final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), listener,
+ WebSocketClientConfig.custom().build(), null);
+ final WebSocketOutbound outbound = new WebSocketOutbound(state);
+ final WebSocketInbound inbound = new WebSocketInbound(state, outbound);
+
+ final WebSocketFrameWriter writer = new WebSocketFrameWriter();
+ final ByteBuffer close = writer.frame(FrameOpcode.CLOSE, ByteBuffer.wrap(new byte[] { 1 }), true, false);
+ inbound.onInputReady(state.session, close);
+
+ Assertions.assertEquals(1002, listener.closeCode.get());
+ }
+
+ @Test
+ void oversizedMessageTriggersClose() {
+ final CapturingListener listener = new CapturingListener();
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom().setMaxMessageSize(1).build();
+ final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), listener, cfg, null);
+ final WebSocketOutbound outbound = new WebSocketOutbound(state);
+ final WebSocketInbound inbound = new WebSocketInbound(state, outbound);
+
+ final WebSocketFrameWriter writer = new WebSocketFrameWriter();
+ final ByteBuffer text = writer.frame(FrameOpcode.TEXT, ByteBuffer.wrap(new byte[] { 1, 2 }), true, false);
+ inbound.onInputReady(state.session, text);
+
+ Assertions.assertEquals(1009, listener.closeCode.get());
+ }
+
+ @Test
+ void rsv1WithoutExtensionTriggersClose() {
+ final CapturingListener listener = new CapturingListener();
+ final WebSocketSessionState state = new WebSocketSessionState(new StubSession(), listener,
+ WebSocketClientConfig.custom().build(), null);
+ final WebSocketOutbound outbound = new WebSocketOutbound(state);
+ final WebSocketInbound inbound = new WebSocketInbound(state, outbound);
+
+ final WebSocketFrameWriter writer = new WebSocketFrameWriter();
+ final ByteBuffer text = writer.frameWithRSV(FrameOpcode.TEXT, ByteBuffer.wrap(new byte[] { 1 }), true, false,
+ FrameHeaderBits.RSV1);
+ inbound.onInputReady(state.session, text);
+
+ Assertions.assertEquals(1002, listener.closeCode.get());
+ }
+
+ private static final class CapturingListener implements WebSocketListener {
+ private final AtomicInteger pingCount = new AtomicInteger();
+ private final AtomicInteger closeCode = new AtomicInteger(-1);
+ private final AtomicReference text = new AtomicReference<>();
+
+ @Override
+ public void onPing(final ByteBuffer data) {
+ pingCount.incrementAndGet();
+ }
+
+ @Override
+ public void onText(final CharBuffer data, final boolean last) {
+ text.set(data);
+ }
+
+ @Override
+ public void onClose(final int statusCode, final String reason) {
+ closeCode.compareAndSet(-1, statusCode);
+ }
+ }
+
+ private static final class StubSession implements ProtocolIOSession {
+ private final Lock lock = new ReentrantLock();
+ private volatile Timeout socketTimeout = Timeout.DISABLED;
+ private volatile IOSession.Status status = IOSession.Status.ACTIVE;
+
+ @Override
+ public int read(final ByteBuffer dst) {
+ return 0;
+ }
+
+ @Override
+ public int write(final ByteBuffer src) {
+ return 0;
+ }
+
+ @Override
+ public boolean isOpen() {
+ return status == IOSession.Status.ACTIVE;
+ }
+
+ @Override
+ public void close() {
+ status = IOSession.Status.CLOSED;
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ status = IOSession.Status.CLOSED;
+ }
+
+ @Override
+ public String getId() {
+ return "stub";
+ }
+
+ @Override
+ public IOEventHandler getHandler() {
+ return null;
+ }
+
+ @Override
+ public void upgrade(final IOEventHandler handler) {
+ }
+
+ @Override
+ public Lock getLock() {
+ return lock;
+ }
+
+ @Override
+ public void enqueue(final Command command, final Command.Priority priority) {
+ }
+
+ @Override
+ public boolean hasCommands() {
+ return false;
+ }
+
+ @Override
+ public Command poll() {
+ return null;
+ }
+
+ @Override
+ public ByteChannel channel() {
+ return this;
+ }
+
+ @Override
+ public SocketAddress getRemoteAddress() {
+ return null;
+ }
+
+ @Override
+ public SocketAddress getLocalAddress() {
+ return null;
+ }
+
+ @Override
+ public int getEventMask() {
+ return 0;
+ }
+
+ @Override
+ public void setEventMask(final int ops) {
+ }
+
+ @Override
+ public void setEvent(final int op) {
+ }
+
+ @Override
+ public void clearEvent(final int op) {
+ }
+
+ @Override
+ public IOSession.Status getStatus() {
+ return status;
+ }
+
+ @Override
+ public Timeout getSocketTimeout() {
+ return socketTimeout;
+ }
+
+ @Override
+ public void setSocketTimeout(final Timeout timeout) {
+ socketTimeout = timeout != null ? timeout : Timeout.DISABLED;
+ }
+
+ @Override
+ public long getLastReadTime() {
+ return 0;
+ }
+
+ @Override
+ public long getLastWriteTime() {
+ return 0;
+ }
+
+ @Override
+ public long getLastEventTime() {
+ return 0;
+ }
+
+ @Override
+ public void updateReadTime() {
+ }
+
+ @Override
+ public void updateWriteTime() {
+ }
+
+ @Override
+ public void startTls(final SSLContext sslContext, final NamedEndpoint endpoint, final SSLBufferMode sslBufferMode,
+ final SSLSessionInitializer initializer, final SSLSessionVerifier verifier,
+ final Timeout handshakeTimeout) throws UnsupportedOperationException {
+ }
+
+ @Override
+ public TlsDetails getTlsDetails() {
+ return null;
+ }
+
+ @Override
+ public NamedEndpoint getInitialEndpoint() {
+ return null;
+ }
+
+ @Override
+ public void registerProtocol(final String protocolId, final ProtocolUpgradeHandler upgradeHandler) {
+ }
+
+ @Override
+ public void switchProtocol(final String protocolId, final FutureCallback callback)
+ throws UnsupportedOperationException {
+ if (callback != null) {
+ callback.failed(new UnsupportedOperationException());
+ }
+ }
+ }
+}
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..d69909eacd
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundCompressionTest.java
@@ -0,0 +1,181 @@
+/*
+ * ====================================================================
+ * 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) {
+ }
+
+ 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/java/org/apache/hc/client5/http/websocket/transport/WsOutboundQueueTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundQueueTest.java
new file mode 100644
index 0000000000..f6e5fe6ac2
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/client5/http/websocket/transport/WsOutboundQueueTest.java
@@ -0,0 +1,234 @@
+/*
+ * ====================================================================
+ * 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.channels.ByteChannel;
+import java.net.SocketAddress;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+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.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.net.NamedEndpoint;
+import org.apache.hc.core5.reactor.Command;
+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.reactor.ProtocolUpgradeHandler;
+import org.apache.hc.core5.reactor.ssl.SSLBufferMode;
+import org.apache.hc.core5.reactor.ssl.SSLSessionInitializer;
+import org.apache.hc.core5.reactor.ssl.SSLSessionVerifier;
+import org.apache.hc.core5.reactor.ssl.TlsDetails;
+import org.apache.hc.core5.util.Timeout;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class WsOutboundQueueTest {
+
+ @Test
+ void dataQueueLimitRejectsExcess() {
+ final WebSocketClientConfig cfg = WebSocketClientConfig.custom()
+ .setMaxOutboundDataBytes(7)
+ .build();
+ final ProtocolIOSession session = new StubSession();
+ final WebSocketSessionState state = new WebSocketSessionState(session, new WebSocketListener() { }, cfg, null);
+ final WebSocketOutbound outbound = new WebSocketOutbound(state);
+ final WebSocket ws = outbound.facade();
+
+ Assertions.assertTrue(ws.sendBinary(ByteBuffer.wrap(new byte[] {1}), true));
+ Assertions.assertFalse(ws.sendBinary(ByteBuffer.wrap(new byte[] {1}), true));
+ }
+
+ private static final class StubSession implements ProtocolIOSession {
+ private final Lock lock = new ReentrantLock();
+ private volatile Timeout socketTimeout = Timeout.DISABLED;
+ private volatile IOSession.Status status = IOSession.Status.ACTIVE;
+
+ @Override
+ public int read(final ByteBuffer dst) {
+ return 0;
+ }
+
+ @Override
+ public int write(final ByteBuffer src) {
+ return 0;
+ }
+
+ @Override
+ public boolean isOpen() {
+ return status == IOSession.Status.ACTIVE;
+ }
+
+ @Override
+ public void close() {
+ status = IOSession.Status.CLOSED;
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ status = IOSession.Status.CLOSED;
+ }
+
+ @Override
+ public String getId() {
+ return "stub";
+ }
+
+ @Override
+ public IOEventHandler getHandler() {
+ return null;
+ }
+
+ @Override
+ public void upgrade(final IOEventHandler handler) {
+ }
+
+ @Override
+ public Lock getLock() {
+ return lock;
+ }
+
+ @Override
+ public void enqueue(final Command command, final Command.Priority priority) {
+ }
+
+ @Override
+ public boolean hasCommands() {
+ return false;
+ }
+
+ @Override
+ public Command poll() {
+ return null;
+ }
+
+ @Override
+ public ByteChannel channel() {
+ return this;
+ }
+
+ @Override
+ public SocketAddress getRemoteAddress() {
+ return null;
+ }
+
+ @Override
+ public SocketAddress getLocalAddress() {
+ return null;
+ }
+
+ @Override
+ public int getEventMask() {
+ return 0;
+ }
+
+ @Override
+ public void setEventMask(final int ops) {
+ }
+
+ @Override
+ public void setEvent(final int op) {
+ }
+
+ @Override
+ public void clearEvent(final int op) {
+ }
+
+ @Override
+ public IOSession.Status getStatus() {
+ return status;
+ }
+
+ @Override
+ public Timeout getSocketTimeout() {
+ return socketTimeout;
+ }
+
+ @Override
+ public void setSocketTimeout(final Timeout timeout) {
+ socketTimeout = timeout != null ? timeout : Timeout.DISABLED;
+ }
+
+ @Override
+ public long getLastReadTime() {
+ return 0;
+ }
+
+ @Override
+ public long getLastWriteTime() {
+ return 0;
+ }
+
+ @Override
+ public long getLastEventTime() {
+ return 0;
+ }
+
+ @Override
+ public void updateReadTime() {
+ }
+
+ @Override
+ public void updateWriteTime() {
+ }
+
+ @Override
+ public void startTls(final SSLContext sslContext, final NamedEndpoint endpoint, final SSLBufferMode sslBufferMode,
+ final SSLSessionInitializer initializer, final SSLSessionVerifier verifier,
+ final Timeout handshakeTimeout) throws UnsupportedOperationException {
+ }
+
+ @Override
+ public TlsDetails getTlsDetails() {
+ return null;
+ }
+
+ @Override
+ public NamedEndpoint getInitialEndpoint() {
+ return null;
+ }
+
+ @Override
+ public void registerProtocol(final String protocolId, final ProtocolUpgradeHandler upgradeHandler) {
+ }
+
+ @Override
+ public void switchProtocol(final String protocolId, final FutureCallback callback)
+ throws UnsupportedOperationException {
+ if (callback != null) {
+ callback.failed(new UnsupportedOperationException());
+ }
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactoryTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactoryTest.java
new file mode 100644
index 0000000000..964ed201ce
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionFactoryTest.java
@@ -0,0 +1,72 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+class PerMessageDeflateExtensionFactoryTest {
+
+ @Test
+ void createsExtension() {
+ final PerMessageDeflateExtensionFactory factory = new PerMessageDeflateExtensionFactory();
+ assertEquals("permessage-deflate", factory.getName());
+ final WebSocketExtension ext = factory.create(
+ new WebSocketExtensionData("permessage-deflate", Collections.emptyMap()), true);
+ assertNotNull(ext);
+ assertEquals("permessage-deflate", ext.getName());
+ }
+
+ @Test
+ void rejectsUnsupportedWindowBits() {
+ final Map params = new LinkedHashMap<>();
+ params.put("client_max_window_bits", "12");
+ final WebSocketExtension ext = new PerMessageDeflateExtensionFactory().create(
+ new WebSocketExtensionData("permessage-deflate", params), true);
+ assertNull(ext);
+ }
+
+ @Test
+ void echoesNegotiatedWindowBitsWhenRequested() {
+ final Map params = new LinkedHashMap<>();
+ params.put("client_max_window_bits", "15");
+ params.put("server_max_window_bits", "15");
+ final WebSocketExtension ext = new PerMessageDeflateExtensionFactory().create(
+ new WebSocketExtensionData("permessage-deflate", params), true);
+ assertNotNull(ext);
+ final WebSocketExtensionData response = ext.getResponseData();
+ assertEquals("15", response.getParameters().get("client_max_window_bits"));
+ assertEquals("15", response.getParameters().get("server_max_window_bits"));
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionTest.java
new file mode 100644
index 0000000000..3e1fcc3997
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/PerMessageDeflateExtensionTest.java
@@ -0,0 +1,88 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.zip.Deflater;
+
+import org.junit.jupiter.api.Test;
+
+class PerMessageDeflateExtensionTest {
+
+ @Test
+ void decodesFragmentedMessage() throws Exception {
+ final byte[] plain = "fragmented message".getBytes(StandardCharsets.UTF_8);
+ final byte[] compressed = deflateWithSyncFlush(plain);
+ final int mid = compressed.length / 2;
+ final ByteBuffer part1 = ByteBuffer.wrap(compressed, 0, mid);
+ final ByteBuffer part2 = ByteBuffer.wrap(compressed, mid, compressed.length - mid);
+
+ final PerMessageDeflateExtension ext = new PerMessageDeflateExtension();
+ final ByteBuffer out1 = ext.decode(WebSocketFrameType.TEXT, false, part1);
+ final ByteBuffer out2 = ext.decode(WebSocketFrameType.CONTINUATION, true, part2);
+
+ final ByteArrayOutputStream joined = new ByteArrayOutputStream();
+ joined.write(toBytes(out1));
+ joined.write(toBytes(out2));
+
+ assertEquals("fragmented message", WebSocketSession.decodeText(ByteBuffer.wrap(joined.toByteArray())));
+ }
+
+ private static byte[] deflateWithSyncFlush(final byte[] input) {
+ final Deflater deflater = new Deflater(Deflater.DEFAULT_COMPRESSION, true);
+ deflater.setInput(input);
+ final ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(128, input.length / 2));
+ final byte[] buffer = new byte[8192];
+ while (!deflater.needsInput()) {
+ final int count = deflater.deflate(buffer, 0, buffer.length, Deflater.SYNC_FLUSH);
+ if (count > 0) {
+ out.write(buffer, 0, count);
+ } else {
+ break;
+ }
+ }
+ deflater.end();
+ final byte[] data = out.toByteArray();
+ if (data.length >= 4) {
+ final byte[] trimmed = new byte[data.length - 4];
+ System.arraycopy(data, 0, trimmed, 0, trimmed.length);
+ return trimmed;
+ }
+ return data;
+ }
+
+ private static byte[] toBytes(final ByteBuffer buf) {
+ final ByteBuffer copy = buf.asReadOnlyBuffer();
+ final byte[] out = new byte[copy.remaining()];
+ copy.get(out);
+ return out;
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketCloseStatusTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketCloseStatusTest.java
new file mode 100644
index 0000000000..cb58d63226
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketCloseStatusTest.java
@@ -0,0 +1,41 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+class WebSocketCloseStatusTest {
+
+ @Test
+ void exposesStatusCodes() {
+ assertEquals(1000, WebSocketCloseStatus.NORMAL.getCode());
+ assertEquals(1002, WebSocketCloseStatus.PROTOCOL_ERROR.getCode());
+ assertEquals(1011, WebSocketCloseStatus.INTERNAL_ERROR.getCode());
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConfigTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConfigTest.java
new file mode 100644
index 0000000000..b8a021c48f
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConfigTest.java
@@ -0,0 +1,60 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+class WebSocketConfigTest {
+
+ @Test
+ void defaultsArePositive() {
+ final WebSocketConfig cfg = WebSocketConfig.DEFAULT;
+ assertEquals(16 * 1024 * 1024, cfg.getMaxFramePayloadSize());
+ assertEquals(64 * 1024 * 1024, cfg.getMaxMessageSize());
+ }
+
+ @Test
+ void customBuilderAppliesLimits() {
+ final WebSocketConfig cfg = WebSocketConfig.custom()
+ .setMaxFramePayloadSize(1024)
+ .setMaxMessageSize(2048)
+ .build();
+ assertEquals(1024, cfg.getMaxFramePayloadSize());
+ assertEquals(2048, cfg.getMaxMessageSize());
+ }
+
+ @Test
+ void invalidSizesThrow() {
+ assertThrows(IllegalArgumentException.class,
+ () -> WebSocketConfig.custom().setMaxFramePayloadSize(0));
+ assertThrows(IllegalArgumentException.class,
+ () -> WebSocketConfig.custom().setMaxMessageSize(0));
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConstantsTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConstantsTest.java
new file mode 100644
index 0000000000..cd8130e863
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketConstantsTest.java
@@ -0,0 +1,44 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+class WebSocketConstantsTest {
+
+ @Test
+ void exposesStandardHeaderNames() {
+ assertEquals("Sec-WebSocket-Key", WebSocketConstants.SEC_WEBSOCKET_KEY);
+ assertEquals("Sec-WebSocket-Version", WebSocketConstants.SEC_WEBSOCKET_VERSION);
+ assertEquals("Sec-WebSocket-Accept", WebSocketConstants.SEC_WEBSOCKET_ACCEPT);
+ assertEquals("Sec-WebSocket-Protocol", WebSocketConstants.SEC_WEBSOCKET_PROTOCOL);
+ assertEquals("Sec-WebSocket-Extensions", WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS);
+ assertEquals("258EAFA5-E914-47DA-95CA-C5AB0DC85B11", WebSocketConstants.WEBSOCKET_GUID);
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExceptionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExceptionTest.java
new file mode 100644
index 0000000000..75a676b141
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExceptionTest.java
@@ -0,0 +1,43 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import org.junit.jupiter.api.Test;
+
+class WebSocketExceptionTest {
+
+ @Test
+ void carriesMessageAndCause() {
+ final RuntimeException cause = new RuntimeException("boom");
+ final WebSocketException ex = new WebSocketException("fail", cause);
+ assertEquals("fail", ex.getMessage());
+ assertSame(cause, ex.getCause());
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionDataTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionDataTest.java
new file mode 100644
index 0000000000..7b61e21339
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionDataTest.java
@@ -0,0 +1,50 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+class WebSocketExtensionDataTest {
+
+ @Test
+ void formatsParametersInOrder() {
+ final Map params = new LinkedHashMap<>();
+ params.put("server_no_context_takeover", null);
+ params.put("client_max_window_bits", "12");
+ final WebSocketExtensionData data = new WebSocketExtensionData("permessage-deflate", params);
+
+ assertEquals("permessage-deflate", data.getName());
+ assertTrue(data.getParameters().containsKey("server_no_context_takeover"));
+ assertEquals("permessage-deflate; server_no_context_takeover; client_max_window_bits=12", data.format());
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiationTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiationTest.java
new file mode 100644
index 0000000000..c1c5a415d2
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionNegotiationTest.java
@@ -0,0 +1,54 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+class WebSocketExtensionNegotiationTest {
+
+ @Test
+ void formatsResponseHeader() {
+ final WebSocketExtensionData data = new WebSocketExtensionData(
+ "permessage-deflate", Collections.singletonMap("client_max_window_bits", "12"));
+ final WebSocketExtensionNegotiation negotiation = new WebSocketExtensionNegotiation(
+ Collections.emptyList(),
+ Collections.singletonList(data));
+ assertEquals("permessage-deflate; client_max_window_bits=12", negotiation.formatResponseHeader());
+ }
+
+ @Test
+ void formatsEmptyHeaderAsNull() {
+ final WebSocketExtensionNegotiation negotiation = new WebSocketExtensionNegotiation(
+ Collections.emptyList(), Collections.emptyList());
+ assertNull(negotiation.formatResponseHeader());
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistryTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistryTest.java
new file mode 100644
index 0000000000..f3b6b7418b
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionRegistryTest.java
@@ -0,0 +1,105 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.nio.ByteBuffer;
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+class WebSocketExtensionRegistryTest {
+
+ @Test
+ void registersAndNegotiatesExtensions() throws Exception {
+ final WebSocketExtensionRegistry registry = new WebSocketExtensionRegistry()
+ .register(new WebSocketExtensionFactory() {
+ @Override
+ public String getName() {
+ return "x-test";
+ }
+
+ @Override
+ public WebSocketExtension create(final WebSocketExtensionData request, final boolean server) {
+ return new WebSocketExtension() {
+ @Override
+ public String getName() {
+ return "x-test";
+ }
+
+ @Override
+ public boolean usesRsv1() {
+ return false;
+ }
+
+ @Override
+ public boolean usesRsv2() {
+ return false;
+ }
+
+ @Override
+ public boolean usesRsv3() {
+ return false;
+ }
+
+ @Override
+ public ByteBuffer decode(final WebSocketFrameType type, final boolean fin, final ByteBuffer payload) {
+ return payload;
+ }
+
+ @Override
+ public ByteBuffer encode(final WebSocketFrameType type, final boolean fin, final ByteBuffer payload) {
+ return payload;
+ }
+
+ @Override
+ public WebSocketExtensionData getResponseData() {
+ return new WebSocketExtensionData("x-test", Collections.emptyMap());
+ }
+ };
+ }
+ });
+
+ final WebSocketExtensionNegotiation negotiation = registry.negotiate(
+ Collections.singletonList(new WebSocketExtensionData("x-test", Collections.emptyMap())),
+ true);
+ assertNotNull(negotiation);
+ assertEquals(1, negotiation.getExtensions().size());
+ assertEquals("x-test", negotiation.getResponseData().get(0).getName());
+ }
+
+ @Test
+ void defaultRegistryContainsPerMessageDeflate() throws Exception {
+ final WebSocketExtensionRegistry registry = WebSocketExtensionRegistry.createDefault();
+ final WebSocketExtensionNegotiation negotiation = registry.negotiate(
+ Collections.singletonList(new WebSocketExtensionData("permessage-deflate", Collections.emptyMap())),
+ true);
+ assertEquals(1, negotiation.getExtensions().size());
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionsTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionsTest.java
new file mode 100644
index 0000000000..5b48cae8f8
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketExtensionsTest.java
@@ -0,0 +1,52 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.hc.core5.http.message.BasicHeader;
+import org.junit.jupiter.api.Test;
+
+class WebSocketExtensionsTest {
+
+ @Test
+ void parsesExtensionsHeader() {
+ final BasicHeader header = new BasicHeader(
+ WebSocketConstants.SEC_WEBSOCKET_EXTENSIONS,
+ "permessage-deflate; client_max_window_bits=12, foo; bar=1");
+ final List data = WebSocketExtensions.parse(header);
+ assertEquals(2, data.size());
+ assertEquals("permessage-deflate", data.get(0).getName());
+ assertEquals("12", data.get(0).getParameters().get("client_max_window_bits"));
+ assertEquals("foo", data.get(1).getName());
+ final Map params = data.get(1).getParameters();
+ assertEquals("1", params.get("bar"));
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameCodecTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameCodecTest.java
new file mode 100644
index 0000000000..762f27c2cf
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameCodecTest.java
@@ -0,0 +1,84 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class WebSocketFrameCodecTest {
+
+ @Test
+ void readsMaskedTextFrame() throws Exception {
+ final byte[] payload = "hi".getBytes(StandardCharsets.UTF_8);
+ final byte[] mask = new byte[] { 1, 2, 3, 4 };
+ final byte[] masked = new byte[payload.length];
+ for (int i = 0; i < payload.length; i++) {
+ masked[i] = (byte) (payload[i] ^ mask[i % 4]);
+ }
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ out.write(0x81);
+ out.write(0x80 | payload.length);
+ out.write(mask);
+ out.write(masked);
+ final WebSocketFrameReader reader = new WebSocketFrameReader(
+ WebSocketConfig.DEFAULT,
+ new ByteArrayInputStream(out.toByteArray()),
+ Collections.emptyList());
+ final WebSocketFrame frame = reader.readFrame();
+ Assertions.assertNotNull(frame);
+ Assertions.assertEquals(WebSocketFrameType.TEXT, frame.getType());
+ Assertions.assertEquals("hi", WebSocketSession.decodeText(frame.getPayload()));
+ }
+
+ @Test
+ void writesUnmaskedServerFrame() throws Exception {
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ final WebSocketFrameWriter writer = new WebSocketFrameWriter(out, Collections.emptyList());
+ writer.writeBinary(ByteBuffer.wrap(new byte[] { 1, 2, 3 }));
+ final byte[] data = out.toByteArray();
+ Assertions.assertEquals((byte) 0x82, data[0]);
+ Assertions.assertEquals((byte) 0x03, data[1]);
+ Assertions.assertEquals((byte) 1, data[2]);
+ Assertions.assertEquals((byte) 2, data[3]);
+ Assertions.assertEquals((byte) 3, data[4]);
+ }
+
+ @Test
+ void encodesAndDecodesPerMessageDeflate() throws Exception {
+ final PerMessageDeflateExtension extension = new PerMessageDeflateExtension();
+ final ByteBuffer payload = ByteBuffer.wrap("compress me".getBytes(StandardCharsets.UTF_8));
+ final ByteBuffer encoded = extension.encode(WebSocketFrameType.TEXT, true, payload);
+ final ByteBuffer decoded = extension.decode(WebSocketFrameType.TEXT, true, encoded);
+ Assertions.assertEquals("compress me", WebSocketSession.decodeText(decoded));
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameReaderTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameReaderTest.java
new file mode 100644
index 0000000000..c70921bbec
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameReaderTest.java
@@ -0,0 +1,163 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+class WebSocketFrameReaderTest {
+
+ private static final byte[] MASK_KEY = new byte[] { 0x11, 0x22, 0x33, 0x44 };
+
+ private static WebSocketFrame readFrame(final ByteBuffer frame, final int maxFrameSize) throws IOException {
+ final ByteBuffer copy = frame.asReadOnlyBuffer();
+ final byte[] data = new byte[copy.remaining()];
+ copy.get(data);
+ final WebSocketFrameReader reader = new WebSocketFrameReader(
+ WebSocketConfig.custom().setMaxFramePayloadSize(maxFrameSize).build(),
+ new ByteArrayInputStream(data),
+ Collections.emptyList());
+ return reader.readFrame();
+ }
+
+ private static ByteBuffer maskedFrame(final int firstByte, final byte[] payload) {
+ final int len = payload.length;
+ final int hdrExtra = len <= 125 ? 0 : len <= 0xFFFF ? 2 : 8;
+ final ByteBuffer buf = ByteBuffer.allocate(2 + hdrExtra + 4 + len);
+ buf.put((byte) firstByte);
+ if (len <= 125) {
+ buf.put((byte) (0x80 | len));
+ } else if (len <= 0xFFFF) {
+ buf.put((byte) (0x80 | 126));
+ buf.putShort((short) len);
+ } else {
+ buf.put((byte) (0x80 | 127));
+ buf.putLong(len);
+ }
+ buf.put(MASK_KEY);
+ for (int i = 0; i < len; i++) {
+ buf.put((byte) (payload[i] ^ MASK_KEY[i % 4]));
+ }
+ buf.flip();
+ return buf;
+ }
+
+ private static ByteBuffer unmaskedFrame(final int firstByte, final byte[] payload) {
+ final int len = payload.length;
+ final int hdrExtra = len <= 125 ? 0 : len <= 0xFFFF ? 2 : 8;
+ final ByteBuffer buf = ByteBuffer.allocate(2 + hdrExtra + len);
+ buf.put((byte) firstByte);
+ if (len <= 125) {
+ buf.put((byte) len);
+ } else if (len <= 0xFFFF) {
+ buf.put((byte) 126);
+ buf.putShort((short) len);
+ } else {
+ buf.put((byte) 127);
+ buf.putLong(len);
+ }
+ buf.put(payload);
+ buf.flip();
+ return buf;
+ }
+
+ @Test
+ void decode_small_text_masked() throws Exception {
+ final byte[] p = "hello".getBytes(StandardCharsets.UTF_8);
+ final ByteBuffer f = maskedFrame(0x81, p); // FIN|TEXT
+ final WebSocketFrame frame = readFrame(f, 8192);
+ assertNotNull(frame);
+ assertEquals(WebSocketFrameType.TEXT, frame.getType());
+ assertEquals("hello", StandardCharsets.UTF_8.decode(frame.getPayload()).toString());
+ }
+
+ @Test
+ void decode_extended_126_length() throws Exception {
+ final byte[] p = new byte[300];
+ for (int i = 0; i < p.length; i++) {
+ p[i] = (byte) (i & 0xFF);
+ }
+ final ByteBuffer f = maskedFrame(0x82, p); // FIN|BINARY
+ final WebSocketFrame frame = readFrame(f, 4096);
+ assertNotNull(frame);
+ assertEquals(WebSocketFrameType.BINARY, frame.getType());
+ final ByteBuffer payload = frame.getPayload();
+ final byte[] got = new byte[p.length];
+ payload.get(got);
+ assertArrayEquals(p, got);
+ }
+
+ @Test
+ void decode_extended_127_length() throws Exception {
+ final int len = 66000;
+ final byte[] p = new byte[len];
+ Arrays.fill(p, (byte) 0xAB);
+ final ByteBuffer f = maskedFrame(0x82, p); // FIN|BINARY
+ final WebSocketFrame frame = readFrame(f, len + 64);
+ assertNotNull(frame);
+ assertEquals(len, frame.getPayload().remaining());
+ }
+
+ @Test
+ void unmasked_client_frame_is_rejected() {
+ final ByteBuffer f = unmaskedFrame(0x81, new byte[0]); // FIN|TEXT, no MASK
+ assertThrows(WebSocketException.class, () -> readFrame(f, 1024));
+ }
+
+ @Test
+ void rsv_bits_without_extension_is_rejected() {
+ final ByteBuffer f = maskedFrame(0xC1, new byte[0]); // FIN|RSV1|TEXT
+ assertThrows(WebSocketException.class, () -> readFrame(f, 1024));
+ }
+
+ @Test
+ void truncated_frame_throws() {
+ final ByteBuffer f = ByteBuffer.allocate(2);
+ f.put((byte) 0x81);
+ f.put((byte) 0xFE); // MASK|126, but missing extended length and mask
+ f.flip();
+ assertThrows(IOException.class, () -> readFrame(f, 1024));
+ }
+
+ @Test
+ void frame_too_large_throws() {
+ final int len = 2000;
+ final ByteBuffer f = maskedFrame(0x82, new byte[len]);
+ assertThrows(WebSocketException.class, () -> readFrame(f, 1024));
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTest.java
new file mode 100644
index 0000000000..27cf1c2420
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTest.java
@@ -0,0 +1,52 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.ByteBuffer;
+
+import org.junit.jupiter.api.Test;
+
+class WebSocketFrameTest {
+
+ @Test
+ void exposesImmutablePayload() {
+ final ByteBuffer payload = ByteBuffer.wrap(new byte[] { 1, 2, 3 });
+ final WebSocketFrame frame = new WebSocketFrame(true, false, false, false, WebSocketFrameType.BINARY, payload);
+ final ByteBuffer got = frame.getPayload();
+
+ assertTrue(frame.isFin());
+ assertFalse(frame.isRsv1());
+ assertEquals(WebSocketFrameType.BINARY, frame.getType());
+ assertNotSame(payload, got);
+ assertEquals(3, got.remaining());
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTypeTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTypeTest.java
new file mode 100644
index 0000000000..a465077006
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameTypeTest.java
@@ -0,0 +1,50 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class WebSocketFrameTypeTest {
+
+ @Test
+ void resolvesOpcodes() {
+ assertEquals(WebSocketFrameType.TEXT, WebSocketFrameType.fromOpcode(0x1));
+ assertEquals(WebSocketFrameType.BINARY, WebSocketFrameType.fromOpcode(0x2));
+ assertEquals(WebSocketFrameType.PING, WebSocketFrameType.fromOpcode(0x9));
+ assertNull(WebSocketFrameType.fromOpcode(0x3));
+ }
+
+ @Test
+ void identifiesControlFrames() {
+ assertTrue(WebSocketFrameType.PING.isControl());
+ assertTrue(WebSocketFrameType.CLOSE.isControl());
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameWriterTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameWriterTest.java
new file mode 100644
index 0000000000..7ce360d565
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketFrameWriterTest.java
@@ -0,0 +1,109 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+class WebSocketFrameWriterTest {
+
+ @Test
+ void writesSmallBinaryFrame() throws Exception {
+ final byte[] payload = new byte[] {0x01, 0x02, 0x03};
+ final byte[] out = writeBinary(payload, Collections.emptyList());
+
+ assertEquals((byte) 0x82, out[0]); // FIN + BINARY
+ assertEquals(payload.length, out[1] & 0xFF);
+ assertEquals(payload[0], out[2]);
+ assertEquals(payload[1], out[3]);
+ assertEquals(payload[2], out[4]);
+ }
+
+ @Test
+ void writesExtendedLength126() throws Exception {
+ final byte[] payload = new byte[126];
+ final byte[] out = writeBinary(payload, Collections.emptyList());
+
+ assertEquals((byte) 0x82, out[0]);
+ assertEquals(126, out[1] & 0xFF);
+ assertEquals(0, out[2] & 0xFF);
+ assertEquals(126, out[3] & 0xFF);
+ assertEquals(payload.length, out.length - 4);
+ }
+
+ @Test
+ void writesExtendedLength127() throws Exception {
+ final int len = 66000;
+ final byte[] payload = new byte[len];
+ final byte[] out = writeBinary(payload, Collections.emptyList());
+
+ assertEquals((byte) 0x82, out[0]);
+ assertEquals(127, out[1] & 0xFF);
+ final long declared = ((long) (out[2] & 0xFF) << 56)
+ | ((long) (out[3] & 0xFF) << 48)
+ | ((long) (out[4] & 0xFF) << 40)
+ | ((long) (out[5] & 0xFF) << 32)
+ | ((long) (out[6] & 0xFF) << 24)
+ | ((long) (out[7] & 0xFF) << 16)
+ | ((long) (out[8] & 0xFF) << 8)
+ | ((long) (out[9] & 0xFF));
+ assertEquals(len, declared);
+ assertEquals(payload.length, out.length - 10);
+ }
+
+ @Test
+ void setsRsv1WhenExtensionUsesIt() throws Exception {
+ final byte[] payload = new byte[] {0x01};
+ final byte[] out = writeBinary(payload, Collections.singletonList(new Rsv1Extension()));
+ assertTrue((out[0] & 0x40) != 0, "RSV1 must be set");
+ }
+
+ private static byte[] writeBinary(final byte[] payload, final java.util.List extensions) throws Exception {
+ final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ final WebSocketFrameWriter writer = new WebSocketFrameWriter(baos, extensions);
+ writer.writeBinary(ByteBuffer.wrap(payload));
+ return baos.toByteArray();
+ }
+
+ private static final class Rsv1Extension implements WebSocketExtension {
+ @Override
+ public String getName() {
+ return "rsv1-test";
+ }
+
+ @Override
+ public boolean usesRsv1() {
+ return true;
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketHandshakeTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketHandshakeTest.java
new file mode 100644
index 0000000000..8f517d259d
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketHandshakeTest.java
@@ -0,0 +1,60 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket;
+
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.message.BasicClassicHttpRequest;
+import org.apache.hc.core5.http.message.BasicHeader;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class WebSocketHandshakeTest {
+
+ @Test
+ void createsExpectedAcceptKey() throws WebSocketException {
+ final String key = "dGhlIHNhbXBsZSBub25jZQ==";
+ final String expected = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=";
+ Assertions.assertEquals(expected, WebSocketHandshake.createAcceptKey(key));
+ }
+
+ @Test
+ void detectsWebSocketUpgradeRequest() {
+ final BasicClassicHttpRequest request = new BasicClassicHttpRequest("GET", "/chat");
+ request.addHeader(HttpHeaders.CONNECTION, "Upgrade");
+ request.addHeader(HttpHeaders.UPGRADE, "websocket");
+ request.addHeader(WebSocketConstants.SEC_WEBSOCKET_VERSION, "13");
+ request.addHeader(WebSocketConstants.SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ==");
+
+ Assertions.assertTrue(WebSocketHandshake.isWebSocketUpgrade(request));
+ }
+
+ @Test
+ void parsesSubprotocols() {
+ final BasicHeader header = new BasicHeader(WebSocketConstants.SEC_WEBSOCKET_PROTOCOL, "chat, superchat");
+ Assertions.assertEquals(2, WebSocketHandshake.parseSubprotocols(header).size());
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketSessionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketSessionTest.java
new file mode 100644
index 0000000000..c6e93a9278
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/WebSocketSessionTest.java
@@ -0,0 +1,80 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+
+import org.apache.hc.core5.websocket.exceptions.WebSocketProtocolException;
+import org.junit.jupiter.api.Test;
+
+class WebSocketSessionTest {
+
+ @Test
+ void writesTextAndCloseFrames() throws Exception {
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ final WebSocketSession session = new WebSocketSession(
+ WebSocketConfig.DEFAULT,
+ new ByteArrayInputStream(new byte[0]),
+ out,
+ null,
+ null,
+ Collections.emptyList());
+
+ session.sendText("hello");
+ final int afterText = out.size();
+ assertTrue(afterText > 0);
+
+ session.close(1000, "done");
+ final int afterClose = out.size();
+ assertTrue(afterClose > afterText);
+
+ session.close(1000, "done");
+ assertEquals(afterClose, out.size(), "close should be sent once");
+ }
+
+ @Test
+ void decodeTextValidatesUtf8() throws Exception {
+ final ByteBuffer payload = StandardCharsets.UTF_8.encode("ok");
+ assertEquals("ok", WebSocketSession.decodeText(payload));
+ }
+
+ @Test
+ void decodeTextRejectsInvalidUtf8() {
+ final byte[] invalid = new byte[] {(byte) 0xC3, (byte) 0x28};
+ final WebSocketProtocolException ex = org.junit.jupiter.api.Assertions.assertThrows(
+ WebSocketProtocolException.class,
+ () -> WebSocketSession.decodeText(ByteBuffer.wrap(invalid)));
+ assertEquals(1007, ex.closeCode);
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolExceptionTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolExceptionTest.java
new file mode 100644
index 0000000000..53222e52ca
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/exceptions/WebSocketProtocolExceptionTest.java
@@ -0,0 +1,41 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.exceptions;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+class WebSocketProtocolExceptionTest {
+
+ @Test
+ void exposesCloseCode() {
+ final WebSocketProtocolException ex = new WebSocketProtocolException(1002, "protocol");
+ assertEquals(1002, ex.closeCode);
+ assertEquals("protocol", ex.getMessage());
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/ExtensionChainTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/ExtensionChainTest.java
new file mode 100644
index 0000000000..a79abeb83d
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/ExtensionChainTest.java
@@ -0,0 +1,50 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.extension;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+final class ExtensionChainTest {
+
+ @Test
+ void addAndUsePmce_decodeRoundTrip() throws Exception {
+ final ExtensionChain chain = new ExtensionChain();
+ final PerMessageDeflate pmce = new PerMessageDeflate(true, true, true, null, null);
+ chain.add(pmce);
+
+ final byte[] data = "compress me please".getBytes(StandardCharsets.UTF_8);
+
+ final WebSocketExtensionChain.Encoded enc = pmce.newEncoder().encode(data, true, true);
+ final byte[] back = chain.newDecodeChain().decode(enc.payload);
+
+ assertArrayEquals(data, back);
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/MessageDeflateTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/MessageDeflateTest.java
new file mode 100644
index 0000000000..84a3b92f6b
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/extension/MessageDeflateTest.java
@@ -0,0 +1,90 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.extension;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+
+import org.apache.hc.core5.websocket.frame.FrameHeaderBits;
+import org.junit.jupiter.api.Test;
+
+final class MessageDeflateTest {
+
+ @Test
+ void rsvMask_isRSV1() {
+ final PerMessageDeflate pmce = new PerMessageDeflate(true, false, false, null, null);
+ assertEquals(FrameHeaderBits.RSV1, pmce.rsvMask());
+ }
+
+ @Test
+ void encode_setsRSVOnlyOnFirst() {
+ final PerMessageDeflate pmce = new PerMessageDeflate(true, false, false, null, null);
+ final WebSocketExtensionChain.Encoder enc = pmce.newEncoder();
+
+ final byte[] data = "hello".getBytes(StandardCharsets.UTF_8);
+
+ final WebSocketExtensionChain.Encoded first = enc.encode(data, true, false);
+ final WebSocketExtensionChain.Encoded cont = enc.encode(data, false, true);
+
+ assertTrue(first.setRsvOnFirst, "RSV on first fragment");
+ assertFalse(cont.setRsvOnFirst, "no RSV on continuation");
+ assertNotEquals(0, first.payload.length);
+ assertNotEquals(0, cont.payload.length);
+ }
+
+ @Test
+ void roundTrip_message() throws Exception {
+ final PerMessageDeflate pmce = new PerMessageDeflate(true, true, true, null, null);
+ final WebSocketExtensionChain.Encoder enc = pmce.newEncoder();
+ final WebSocketExtensionChain.Decoder dec = pmce.newDecoder();
+
+ final String s = "The quick brown fox jumps over the lazy dog. "
+ + "The quick brown fox jumps over the lazy dog.";
+ final byte[] plain = s.getBytes(StandardCharsets.UTF_8);
+
+ // Single-frame message: first=true, fin=true
+ final byte[] wire = enc.encode(plain, true, true).payload;
+
+ assertTrue(wire.length > 0);
+ assertFalse(endsWithTail(wire), "tail must be stripped on wire");
+
+ final byte[] roundTrip = dec.decode(wire);
+ assertArrayEquals(plain, roundTrip);
+ }
+
+ private static boolean endsWithTail(final byte[] b) {
+ if (b.length < 4) {
+ return false;
+ }
+ return b[b.length - 4] == 0x00 && b[b.length - 3] == 0x00 && (b[b.length - 2] & 0xFF) == 0xFF && (b[b.length - 1] & 0xFF) == 0xFF;
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameHeaderBitsTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameHeaderBitsTest.java
new file mode 100644
index 0000000000..9159a5a5b2
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameHeaderBitsTest.java
@@ -0,0 +1,43 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.frame;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+class FrameHeaderBitsTest {
+
+ @Test
+ void exposesHeaderBitConstants() {
+ assertEquals(0x80, FrameHeaderBits.FIN);
+ assertEquals(0x40, FrameHeaderBits.RSV1);
+ assertEquals(0x20, FrameHeaderBits.RSV2);
+ assertEquals(0x10, FrameHeaderBits.RSV3);
+ assertEquals(0x80, FrameHeaderBits.MASK_BIT);
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameOpcodeTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameOpcodeTest.java
new file mode 100644
index 0000000000..ccae851593
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameOpcodeTest.java
@@ -0,0 +1,50 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.frame;
+
+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.junit.jupiter.api.Test;
+
+class FrameOpcodeTest {
+
+ @Test
+ void identifiesOpCodes() {
+ assertTrue(FrameOpcode.isControl(FrameOpcode.PING));
+ assertTrue(FrameOpcode.isData(FrameOpcode.TEXT));
+ assertTrue(FrameOpcode.isContinuation(FrameOpcode.CONT));
+ assertFalse(FrameOpcode.isData(FrameOpcode.CONT));
+ }
+
+ @Test
+ void namesOpcodes() {
+ assertEquals("TEXT", FrameOpcode.name(FrameOpcode.TEXT));
+ assertEquals("0x3", FrameOpcode.name(0x3));
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameWriterTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameWriterTest.java
new file mode 100644
index 0000000000..f32785392d
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/frame/FrameWriterTest.java
@@ -0,0 +1,188 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.frame;
+
+import static org.apache.hc.core5.websocket.frame.FrameHeaderBits.RSV1;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
+
+class FrameWriterTest {
+
+ private static class Parsed {
+ int b0;
+ int b1;
+ int opcode;
+ boolean fin;
+ boolean mask;
+ long len;
+ final byte[] maskKey = new byte[4];
+ int headerLen;
+ ByteBuffer payloadSlice;
+ }
+
+ private static Parsed parse(final ByteBuffer frame) {
+ final ByteBuffer frameCopy = frame.asReadOnlyBuffer();
+ final Parsed r = new Parsed();
+ r.b0 = frameCopy.get() & 0xFF;
+ r.fin = (r.b0 & 0x80) != 0;
+ r.opcode = r.b0 & 0x0F;
+
+ r.b1 = frameCopy.get() & 0xFF;
+ r.mask = (r.b1 & 0x80) != 0;
+ final int low = r.b1 & 0x7F;
+ if (low <= 125) {
+ r.len = low;
+ } else if (low == 126) {
+ r.len = frameCopy.getShort() & 0xFFFF;
+ } else {
+ r.len = frameCopy.getLong();
+ }
+
+ if (r.mask) {
+ frameCopy.get(r.maskKey);
+ }
+ r.headerLen = frameCopy.position();
+ r.payloadSlice = frameCopy.slice();
+ return r;
+ }
+
+ private static byte[] unmask(final Parsed p) {
+ final byte[] out = new byte[(int) p.len];
+ for (int i = 0; i < out.length; i++) {
+ int b = p.payloadSlice.get(i) & 0xFF;
+ b ^= p.maskKey[i & 3] & 0xFF;
+ out[i] = (byte) b;
+ }
+ return out;
+ }
+
+ @Test
+ void text_small_masked_roundtrip() {
+ final WebSocketFrameWriter w = new WebSocketFrameWriter();
+ final ByteBuffer f = w.text("hello", true);
+ final Parsed p = parse(f);
+ assertTrue(p.fin);
+ assertEquals(FrameOpcode.TEXT, p.opcode);
+ assertTrue(p.mask, "client frame must be masked");
+ assertEquals(5, p.len);
+ assertArrayEquals("hello".getBytes(StandardCharsets.UTF_8), unmask(p));
+ }
+
+ @Test
+ void binary_len_126_masked_roundtrip() {
+ final byte[] payload = new byte[300];
+ for (int i = 0; i < payload.length; i++) {
+ payload[i] = (byte) (i & 0xFF);
+ }
+
+ final WebSocketFrameWriter w = new WebSocketFrameWriter();
+ final ByteBuffer f = w.binary(ByteBuffer.wrap(payload), true);
+
+ final Parsed p = parse(f);
+ assertTrue(p.mask);
+ assertEquals(FrameOpcode.BINARY, p.opcode);
+ assertEquals(300, p.len);
+ assertArrayEquals(payload, unmask(p));
+ }
+
+ @Test
+ void binary_len_127_masked_roundtrip() {
+ final int len = 70000;
+ final byte[] payload = new byte[len];
+ Arrays.fill(payload, (byte) 0xA5);
+
+ final WebSocketFrameWriter w = new WebSocketFrameWriter();
+ final ByteBuffer f = w.binary(ByteBuffer.wrap(payload), true);
+
+ final Parsed p = parse(f);
+ assertTrue(p.mask);
+ assertEquals(FrameOpcode.BINARY, p.opcode);
+ assertEquals(len, p.len);
+ assertArrayEquals(payload, unmask(p));
+ }
+
+ @Test
+ void rsv1_set_with_frameWithRSV() {
+ final WebSocketFrameWriter w = new WebSocketFrameWriter();
+ final ByteBuffer payload = StandardCharsets.UTF_8.encode("x");
+ // Use RSV1 bit
+ final ByteBuffer f = w.frameWithRSV(FrameOpcode.TEXT, payload, true, true, RSV1);
+ final Parsed p = parse(f);
+ assertTrue(p.fin);
+ assertEquals(FrameOpcode.TEXT, p.opcode);
+ assertTrue((p.b0 & RSV1) != 0, "RSV1 must be set");
+ assertArrayEquals("x".getBytes(StandardCharsets.UTF_8), unmask(p));
+ }
+
+ @Test
+ void close_frame_contains_code_and_reason() {
+ final WebSocketFrameWriter w = new WebSocketFrameWriter();
+ final ByteBuffer f = w.close(1000, "done");
+ final Parsed p = parse(f);
+ assertTrue(p.mask);
+ assertEquals(FrameOpcode.CLOSE, p.opcode);
+ assertTrue(p.len >= 2);
+
+ final byte[] raw = unmask(p);
+ final int code = (raw[0] & 0xFF) << 8 | raw[1] & 0xFF;
+ final String reason = new String(raw, 2, raw.length - 2, StandardCharsets.UTF_8);
+
+ assertEquals(1000, code);
+ assertEquals("done", reason);
+ }
+
+ @Test
+ void closeEcho_masks_and_preserves_payload() {
+ // Build a close payload manually
+ final byte[] reason = "bye".getBytes(StandardCharsets.UTF_8);
+ final ByteBuffer payload = ByteBuffer.allocate(2 + reason.length);
+ payload.put((byte) (1000 >>> 8));
+ payload.put((byte) (1000 & 0xFF));
+ payload.put(reason);
+ payload.flip();
+
+ final WebSocketFrameWriter w = new WebSocketFrameWriter();
+ final ByteBuffer f = w.closeEcho(payload);
+ final Parsed p = parse(f);
+
+ assertTrue(p.mask);
+ assertEquals(FrameOpcode.CLOSE, p.opcode);
+ assertEquals(2 + reason.length, p.len);
+
+ final byte[] got = unmask(p);
+ assertEquals(1000, (got[0] & 0xFF) << 8 | got[1] & 0xFF);
+ assertEquals("bye", new String(got, 2, got.length - 2, StandardCharsets.UTF_8));
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/message/CloseCodecTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/message/CloseCodecTest.java
new file mode 100644
index 0000000000..56f168f921
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/message/CloseCodecTest.java
@@ -0,0 +1,87 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.message;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+final class CloseCodecTest {
+
+ @Test
+ void readEmptyIs1005() {
+ final ByteBuffer empty = ByteBuffer.allocate(0);
+ assertEquals(1005, CloseCodec.readCloseCode(empty.asReadOnlyBuffer()));
+ assertEquals("", CloseCodec.readCloseReason(empty.asReadOnlyBuffer()));
+ }
+
+ @Test
+ void readCodeAndReason() {
+ final ByteBuffer payload = ByteBuffer.allocate(2 + 4);
+ payload.put((byte) 0x03).put((byte) 0xE8); // 1000
+ payload.put(StandardCharsets.UTF_8.encode("done"));
+ payload.flip();
+
+ // Use the SAME buffer so the position advances
+ final ByteBuffer buf = payload.asReadOnlyBuffer();
+ assertEquals(1000, CloseCodec.readCloseCode(buf)); // advances position by 2
+ assertEquals("done", CloseCodec.readCloseReason(buf)); // reads remaining bytes only
+ }
+
+ @Test
+ void validateCloseCodes() {
+ assertTrue(CloseCodec.isValidToSend(1000));
+ assertTrue(CloseCodec.isValidToReceive(1000));
+ assertTrue(CloseCodec.isValidToSend(3000));
+ assertTrue(CloseCodec.isValidToReceive(3000));
+
+ assertFalse(CloseCodec.isValidToSend(1005));
+ assertFalse(CloseCodec.isValidToReceive(1005));
+ assertFalse(CloseCodec.isValidToSend(1006));
+ assertFalse(CloseCodec.isValidToReceive(1006));
+ assertFalse(CloseCodec.isValidToSend(1015));
+ assertFalse(CloseCodec.isValidToReceive(1015));
+
+ assertFalse(CloseCodec.isValidToSend(2000));
+ assertFalse(CloseCodec.isValidToReceive(2000));
+ }
+
+ @Test
+ void truncateReasonUtf8_capsAt123Bytes() {
+ final StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < 130; i++) {
+ sb.append('a');
+ }
+ final String truncated = CloseCodec.truncateReasonUtf8(sb.toString());
+ assertEquals(123, truncated.getBytes(StandardCharsets.UTF_8).length);
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketContextKeysTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketContextKeysTest.java
new file mode 100644
index 0000000000..6c5d057aa2
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketContextKeysTest.java
@@ -0,0 +1,39 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+class WebSocketContextKeysTest {
+
+ @Test
+ void exposesConnectionKey() {
+ assertEquals("httpcore.websocket.connection", WebSocketContextKeys.CONNECTION);
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerBootstrapTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerBootstrapTest.java
new file mode 100644
index 0000000000..0f7d786313
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerBootstrapTest.java
@@ -0,0 +1,130 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import java.lang.reflect.Field;
+import java.net.SocketAddress;
+
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.URIScheme;
+import org.apache.hc.core5.http.config.Http1Config;
+import org.apache.hc.core5.http.nio.ssl.TlsStrategy;
+import org.apache.hc.core5.http2.config.H2Config;
+import org.apache.hc.core5.net.NamedEndpoint;
+import org.apache.hc.core5.reactor.ssl.TransportSecurityLayer;
+import org.apache.hc.core5.util.Timeout;
+import org.apache.hc.core5.websocket.WebSocketHandler;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class WebSocketH2ServerBootstrapTest {
+
+ @Test
+ void setH2ConfigDisablesPush() {
+ final WebSocketH2ServerBootstrap bootstrap = WebSocketH2ServerBootstrap.bootstrap();
+ bootstrap.setH2Config(H2Config.custom().setPushEnabled(true).build());
+
+ final H2Config cfg = getField(bootstrap, "h2Config", H2Config.class);
+ Assertions.assertNotNull(cfg);
+ Assertions.assertFalse(cfg.isPushEnabled());
+ }
+
+ @Test
+ void setH2ConfigNullUsesDefault() {
+ final WebSocketH2ServerBootstrap bootstrap = WebSocketH2ServerBootstrap.bootstrap();
+ bootstrap.setH2Config(null);
+
+ final H2Config cfg = getField(bootstrap, "h2Config", H2Config.class);
+ Assertions.assertNotNull(cfg);
+ Assertions.assertFalse(cfg.isPushEnabled());
+ }
+
+ @Test
+ void setHttp1ConfigNullUsesDefault() {
+ final WebSocketH2ServerBootstrap bootstrap = WebSocketH2ServerBootstrap.bootstrap();
+ bootstrap.setHttp1Config(null);
+ final Http1Config cfg = getField(bootstrap, "http1Config", Http1Config.class);
+ Assertions.assertSame(Http1Config.DEFAULT, cfg);
+ }
+
+ @Test
+ void createUsesHttpsWhenTlsStrategyProvided() {
+ final WebSocketH2ServerBootstrap bootstrap = WebSocketH2ServerBootstrap.bootstrap()
+ .setTlsStrategy(new StubTlsStrategy());
+ bootstrap.register("/", WebSocketH2ServerBootstrapTest::noopHandler);
+ final WebSocketH2Server server = bootstrap.create();
+ final URIScheme scheme = getField(server, "scheme", URIScheme.class);
+ Assertions.assertEquals(URIScheme.HTTPS, scheme);
+ }
+
+ @Test
+ void createUsesHttpWhenNoTlsStrategy() {
+ final WebSocketH2ServerBootstrap bootstrap = WebSocketH2ServerBootstrap.bootstrap();
+ bootstrap.register("/", WebSocketH2ServerBootstrapTest::noopHandler);
+ final WebSocketH2Server server = bootstrap.create();
+ final URIScheme scheme = getField(server, "scheme", URIScheme.class);
+ Assertions.assertEquals(URIScheme.HTTP, scheme);
+ }
+
+ private static final class StubTlsStrategy implements TlsStrategy {
+ @Override
+ public boolean upgrade(final TransportSecurityLayer sessionLayer,
+ final HttpHost host,
+ final SocketAddress localAddress,
+ final SocketAddress remoteAddress,
+ final Object attachment,
+ final Timeout handshakeTimeout) {
+ return true;
+ }
+
+ @Override
+ public void upgrade(final TransportSecurityLayer sessionLayer,
+ final NamedEndpoint endpoint,
+ final Object attachment,
+ final Timeout handshakeTimeout,
+ final FutureCallback callback) {
+ if (callback != null) {
+ callback.completed(sessionLayer);
+ }
+ }
+ }
+
+ private static T getField(final Object target, final String name, final Class type) {
+ try {
+ final Field field = target.getClass().getDeclaredField(name);
+ field.setAccessible(true);
+ return type.cast(field.get(target));
+ } catch (final Exception ex) {
+ throw new AssertionError("Failed to access field " + name, ex);
+ }
+ }
+
+ private static WebSocketHandler noopHandler() {
+ return new WebSocketHandler() { };
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandlerTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandlerTest.java
new file mode 100644
index 0000000000..ae88106701
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerExchangeHandlerTest.java
@@ -0,0 +1,118 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.core5.websocket.server;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.HttpRequest;
+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.message.BasicHttpRequest;
+import org.apache.hc.core5.http.nio.AsyncPushProducer;
+import org.apache.hc.core5.http.nio.ResponseChannel;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.http.protocol.HttpCoreContext;
+import org.apache.hc.core5.websocket.WebSocketConstants;
+import org.apache.hc.core5.websocket.WebSocketExtensionRegistry;
+import org.apache.hc.core5.websocket.WebSocketHandler;
+import org.junit.jupiter.api.Test;
+
+class WebSocketH2ServerExchangeHandlerTest {
+
+ private static final class CapturingResponseChannel implements ResponseChannel {
+ private HttpResponse response;
+
+ @Override
+ public void sendInformation(final HttpResponse response, final HttpContext context) {
+ // not used
+ }
+
+ @Override
+ public void sendResponse(final HttpResponse response, final EntityDetails entityDetails, final HttpContext context) {
+ this.response = response;
+ }
+
+ @Override
+ public void pushPromise(final HttpRequest promise, final AsyncPushProducer responseProducer, final HttpContext context) {
+ // not used
+ }
+
+ HttpResponse getResponse() {
+ return response;
+ }
+ }
+
+ @Test
+ void rejectsNonConnectMethod() throws Exception {
+ final WebSocketH2ServerExchangeHandler handler = new WebSocketH2ServerExchangeHandler(
+ new WebSocketHandler() {
+ }, null, WebSocketExtensionRegistry.createDefault());
+
+ final HttpRequest request = new BasicHttpRequest(Method.GET, "/");
+ request.addHeader(WebSocketConstants.PSEUDO_PROTOCOL, "websocket");
+
+ final CapturingResponseChannel channel = new CapturingResponseChannel();
+ handler.handleRequest(request, null, channel, HttpCoreContext.create());
+
+ assertNotNull(channel.getResponse());
+ assertEquals(HttpStatus.SC_BAD_REQUEST, channel.getResponse().getCode());
+ }
+
+ @Test
+ void rejectsMissingProtocolHeader() throws Exception {
+ final WebSocketH2ServerExchangeHandler handler = new WebSocketH2ServerExchangeHandler(
+ new WebSocketHandler() {
+ }, null, WebSocketExtensionRegistry.createDefault());
+
+ final HttpRequest request = new BasicHttpRequest(Method.CONNECT, "/echo");
+
+ final CapturingResponseChannel channel = new CapturingResponseChannel();
+ handler.handleRequest(request, null, channel, HttpCoreContext.create());
+
+ assertNotNull(channel.getResponse());
+ assertEquals(HttpStatus.SC_BAD_REQUEST, channel.getResponse().getCode());
+ }
+
+ @Test
+ void rejectsUnknownProtocol() throws Exception {
+ final WebSocketH2ServerExchangeHandler handler = new WebSocketH2ServerExchangeHandler(
+ new WebSocketHandler() {
+ }, null, WebSocketExtensionRegistry.createDefault());
+
+ final HttpRequest request = new BasicHttpRequest(Method.CONNECT, "/echo");
+ request.addHeader(WebSocketConstants.PSEUDO_PROTOCOL, "chat");
+
+ final CapturingResponseChannel channel = new CapturingResponseChannel();
+ handler.handleRequest(request, null, channel, HttpCoreContext.create());
+
+ assertNotNull(channel.getResponse());
+ assertEquals(HttpStatus.SC_BAD_REQUEST, channel.getResponse().getCode());
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerTest.java
new file mode 100644
index 0000000000..5cf29039ae
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketH2ServerTest.java
@@ -0,0 +1,168 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Future;
+
+import org.apache.hc.core5.http.URIScheme;
+import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncServer;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.reactor.IOReactorConfig;
+import org.apache.hc.core5.reactor.ListenerEndpoint;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class WebSocketH2ServerTest {
+
+ @Test
+ void startUsesEndpointAddressAndScheme() throws Exception {
+ final InetAddress local = InetAddress.getLoopbackAddress();
+ final InetSocketAddress endpointAddress = new InetSocketAddress("127.0.0.2", 12345);
+ final StubHttpAsyncServer server = new StubHttpAsyncServer(endpointAddress);
+ final WebSocketH2Server ws = new WebSocketH2Server(server, local, 0, URIScheme.HTTPS);
+ try {
+ ws.start();
+ Assertions.assertTrue(server.started);
+ Assertions.assertEquals(URIScheme.HTTPS, server.lastScheme);
+ Assertions.assertEquals(12345, ws.getLocalPort());
+ Assertions.assertEquals("127.0.0.2", ws.getInetAddress().getHostAddress());
+ } finally {
+ ws.stop();
+ }
+ }
+
+ @Test
+ void startPropagatesIOExceptionFromListen() {
+ final StubHttpAsyncServer server = new StubHttpAsyncServer(null);
+ server.failWith = new IOException("boom");
+ final WebSocketH2Server ws = new WebSocketH2Server(server, null, 0, URIScheme.HTTP);
+ Assertions.assertThrows(IOException.class, ws::start);
+ }
+
+ @Test
+ void accessorsReturnConfiguredValuesBeforeStart() {
+ final InetAddress local = InetAddress.getLoopbackAddress();
+ final WebSocketH2Server ws = new WebSocketH2Server(new StubHttpAsyncServer(null), local, 8443, URIScheme.HTTP);
+ Assertions.assertEquals(local, ws.getInetAddress());
+ Assertions.assertEquals(8443, ws.getLocalPort());
+ }
+
+ @Test
+ void stopAndShutdownDelegateToServer() {
+ final StubHttpAsyncServer server = new StubHttpAsyncServer(null);
+ final WebSocketH2Server ws = new WebSocketH2Server(server, null, 0, URIScheme.HTTP);
+ ws.initiateShutdown();
+ Assertions.assertTrue(server.shutdownCalled);
+ ws.stop();
+ Assertions.assertEquals(CloseMode.GRACEFUL, server.closeMode);
+ }
+
+ private static final class StubHttpAsyncServer extends HttpAsyncServer {
+ private final InetSocketAddress endpointAddress;
+ private boolean started;
+ private boolean shutdownCalled;
+ private CloseMode closeMode;
+ private URIScheme lastScheme;
+ private SocketAddress lastAddress;
+ private IOException failWith;
+
+ StubHttpAsyncServer(final InetSocketAddress endpointAddress) {
+ super((ioSession, attachment) -> null,
+ IOReactorConfig.DEFAULT,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null);
+ this.endpointAddress = endpointAddress;
+ }
+
+ @Override
+ public void start() {
+ started = true;
+ }
+
+ @Override
+ public Future listen(final SocketAddress address, final URIScheme scheme) {
+ lastAddress = address;
+ lastScheme = scheme;
+ final CompletableFuture future = new CompletableFuture<>();
+ if (failWith != null) {
+ future.completeExceptionally(failWith);
+ } else {
+ final SocketAddress effective = endpointAddress != null ? endpointAddress : address;
+ future.complete(new StubListenerEndpoint(effective));
+ }
+ return future;
+ }
+
+ @Override
+ public void initiateShutdown() {
+ shutdownCalled = true;
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ this.closeMode = closeMode;
+ }
+ }
+
+ private static final class StubListenerEndpoint implements ListenerEndpoint {
+ private final SocketAddress address;
+ private boolean closed;
+
+ StubListenerEndpoint(final SocketAddress address) {
+ this.address = address;
+ }
+
+ @Override
+ public SocketAddress getAddress() {
+ return address;
+ }
+
+ @Override
+ public boolean isClosed() {
+ return closed;
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ closed = true;
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketHttpServiceTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketHttpServiceTest.java
new file mode 100644
index 0000000000..6394f94ef5
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketHttpServiceTest.java
@@ -0,0 +1,148 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.net.SocketAddress;
+
+import javax.net.ssl.SSLSession;
+
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.EndpointDetails;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.ProtocolVersion;
+import org.apache.hc.core5.http.io.HttpServerConnection;
+import org.apache.hc.core5.http.io.HttpServerRequestHandler;
+import org.apache.hc.core5.http.protocol.HttpCoreContext;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.http.protocol.HttpProcessor;
+import org.apache.hc.core5.http.impl.HttpProcessors;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.util.Timeout;
+import org.junit.jupiter.api.Test;
+
+class WebSocketHttpServiceTest {
+
+ @Test
+ void setsConnectionInContext() throws Exception {
+ final HttpProcessor processor = HttpProcessors.server();
+ final HttpServerRequestHandler handler = (request, trigger, context) -> {
+ };
+ final WebSocketHttpService service = new WebSocketHttpService(processor, handler, null, null, null);
+ final HttpContext context = HttpCoreContext.create();
+
+ final FailingConnection connection = new FailingConnection();
+ final IOException thrown = assertThrows(IOException.class,
+ () -> service.handleRequest(connection, context));
+ assertEquals("boom", thrown.getMessage());
+ assertEquals(connection, context.getAttribute(WebSocketContextKeys.CONNECTION));
+ }
+
+ private static final class FailingConnection implements HttpServerConnection {
+ @Override
+ public ClassicHttpRequest receiveRequestHeader() throws HttpException, IOException {
+ throw new IOException("boom");
+ }
+
+ @Override
+ public void receiveRequestEntity(final ClassicHttpRequest request) throws HttpException, IOException {
+ }
+
+ @Override
+ public void sendResponseHeader(final ClassicHttpResponse response) throws HttpException, IOException {
+ }
+
+ @Override
+ public void sendResponseEntity(final ClassicHttpResponse response) throws HttpException, IOException {
+ }
+
+ @Override
+ public boolean isDataAvailable(final Timeout timeout) {
+ return false;
+ }
+
+ @Override
+ public boolean isStale() {
+ return false;
+ }
+
+ @Override
+ public void flush() {
+ }
+
+ @Override
+ public void close() throws IOException {
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ }
+
+ @Override
+ public Timeout getSocketTimeout() {
+ return Timeout.ZERO_MILLISECONDS;
+ }
+
+ @Override
+ public void setSocketTimeout(final Timeout timeout) {
+ }
+
+ @Override
+ public EndpointDetails getEndpointDetails() {
+ return null;
+ }
+
+ @Override
+ public SocketAddress getLocalAddress() {
+ return null;
+ }
+
+ @Override
+ public SocketAddress getRemoteAddress() {
+ return null;
+ }
+
+ @Override
+ public ProtocolVersion getProtocolVersion() {
+ return null;
+ }
+
+ @Override
+ public SSLSession getSSLSession() {
+ return null;
+ }
+
+ @Override
+ public boolean isOpen() {
+ return true;
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrapTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrapTest.java
new file mode 100644
index 0000000000..18acfe471e
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerBootstrapTest.java
@@ -0,0 +1,44 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.apache.hc.core5.websocket.WebSocketHandler;
+import org.junit.jupiter.api.Test;
+
+class WebSocketServerBootstrapTest {
+
+ @Test
+ void createsServerWithDefaults() {
+ final WebSocketServer server = WebSocketServerBootstrap.bootstrap()
+ .register("/ws", () -> new WebSocketHandler() {
+ })
+ .create();
+ assertNotNull(server);
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactoryTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactoryTest.java
new file mode 100644
index 0000000000..e9111188c1
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerConnectionFactoryTest.java
@@ -0,0 +1,52 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.net.ServerSocket;
+import java.net.Socket;
+
+import org.junit.jupiter.api.Test;
+
+class WebSocketServerConnectionFactoryTest {
+
+ @Test
+ void createsBoundConnection() throws Exception {
+ final ServerSocket server = new ServerSocket(0);
+ final Socket client = new Socket("127.0.0.1", server.getLocalPort());
+ final Socket socket = server.accept();
+ client.close();
+ server.close();
+
+ final WebSocketServerConnectionFactory factory = new WebSocketServerConnectionFactory("http", null, null);
+ final WebSocketServerConnection conn = factory.createConnection(socket);
+ assertNotNull(conn.getSocketInputStream());
+ assertNotNull(conn.getSocketOutputStream());
+ conn.close();
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessorTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessorTest.java
new file mode 100644
index 0000000000..31ee63b14a
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerProcessorTest.java
@@ -0,0 +1,146 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.hc.core5.websocket.WebSocketConfig;
+import org.apache.hc.core5.websocket.WebSocketHandler;
+import org.apache.hc.core5.websocket.WebSocketSession;
+import org.junit.jupiter.api.Test;
+
+class WebSocketServerProcessorTest {
+
+ private static final byte[] MASK = new byte[]{1, 2, 3, 4};
+
+ private static byte[] maskedFrame(final int opcode, final byte[] payload) {
+ final int len = payload.length;
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ out.write(0x80 | (opcode & 0x0F));
+ if (len <= 125) {
+ out.write(0x80 | len);
+ } else if (len <= 0xFFFF) {
+ out.write(0x80 | 126);
+ out.write((len >> 8) & 0xFF);
+ out.write(len & 0xFF);
+ } else {
+ out.write(0x80 | 127);
+ final long l = len;
+ for (int i = 7; i >= 0; i--) {
+ out.write((int) ((l >> (i * 8)) & 0xFF));
+ }
+ }
+ out.write(MASK, 0, MASK.length);
+ for (int i = 0; i < len; i++) {
+ out.write(payload[i] ^ MASK[i % 4]);
+ }
+ return out.toByteArray();
+ }
+
+ private static byte[] closePayload(final int code, final String reason) {
+ final byte[] reasonBytes = reason != null ? reason.getBytes(StandardCharsets.UTF_8) : new byte[0];
+ final byte[] payload = new byte[2 + reasonBytes.length];
+ payload[0] = (byte) ((code >> 8) & 0xFF);
+ payload[1] = (byte) (code & 0xFF);
+ System.arraycopy(reasonBytes, 0, payload, 2, reasonBytes.length);
+ return payload;
+ }
+
+ @Test
+ void processesTextAndCloseFrames() throws Exception {
+ final byte[] text = "hello".getBytes(StandardCharsets.UTF_8);
+ final byte[] close = closePayload(1000, "bye");
+ final ByteArrayOutputStream frames = new ByteArrayOutputStream();
+ frames.write(maskedFrame(0x1, text));
+ frames.write(maskedFrame(0x8, close));
+
+ final ByteArrayInputStream in = new ByteArrayInputStream(frames.toByteArray());
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ final WebSocketSession session = new WebSocketSession(
+ WebSocketConfig.DEFAULT,
+ in,
+ out,
+ null,
+ null,
+ Collections.emptyList());
+
+ final TrackingHandler handler = new TrackingHandler();
+ final WebSocketServerProcessor processor = new WebSocketServerProcessor(session, handler, 1024);
+ processor.process();
+
+ assertEquals("hello", handler.text);
+ assertEquals(1000, handler.closeCode);
+ assertEquals("bye", handler.closeReason);
+ assertTrue(out.size() > 0, "server should send close response");
+ }
+
+ private static final class TrackingHandler implements WebSocketHandler {
+ private String text;
+ private int closeCode;
+ private String closeReason;
+
+ @Override
+ public void onText(final WebSocketSession session, final String payload) {
+ this.text = payload;
+ }
+
+ @Override
+ public void onBinary(final WebSocketSession session, final ByteBuffer payload) {
+ }
+
+ @Override
+ public void onPing(final WebSocketSession session, final ByteBuffer payload) {
+ }
+
+ @Override
+ public void onPong(final WebSocketSession session, final ByteBuffer payload) {
+ }
+
+ @Override
+ public void onClose(final WebSocketSession session, final int code, final String reason) {
+ this.closeCode = code;
+ this.closeReason = reason;
+ }
+
+ @Override
+ public void onError(final WebSocketSession session, final Exception cause) {
+ }
+
+ @Override
+ public String selectSubprotocol(final List protocols) {
+ return null;
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandlerTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandlerTest.java
new file mode 100644
index 0000000000..056d834c3b
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerRequestHandlerTest.java
@@ -0,0 +1,160 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Supplier;
+
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpRequestMapper;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.io.HttpServerRequestHandler.ResponseTrigger;
+import org.apache.hc.core5.http.message.BasicClassicHttpRequest;
+import org.apache.hc.core5.http.protocol.HttpCoreContext;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.websocket.WebSocketConfig;
+import org.apache.hc.core5.websocket.WebSocketConstants;
+import org.apache.hc.core5.websocket.WebSocketExtensionRegistry;
+import org.apache.hc.core5.websocket.WebSocketHandler;
+import org.apache.hc.core5.websocket.WebSocketSession;
+import org.junit.jupiter.api.Test;
+
+class WebSocketServerRequestHandlerTest {
+
+ @Test
+ void upgradesValidRequest() throws Exception {
+ final AtomicBoolean opened = new AtomicBoolean(false);
+ final Supplier supplier = () -> new WebSocketHandler() {
+ @Override
+ public void onOpen(final WebSocketSession session) {
+ opened.set(true);
+ }
+
+ @Override
+ public void onText(final WebSocketSession session, final String payload) {
+ }
+
+ @Override
+ public void onBinary(final WebSocketSession session, final ByteBuffer payload) {
+ }
+
+ @Override
+ public void onPing(final WebSocketSession session, final ByteBuffer payload) {
+ }
+
+ @Override
+ public void onPong(final WebSocketSession session, final ByteBuffer payload) {
+ }
+
+ @Override
+ public void onClose(final WebSocketSession session, final int code, final String reason) {
+ }
+
+ @Override
+ public void onError(final WebSocketSession session, final Exception cause) {
+ }
+
+ @Override
+ public String selectSubprotocol(final List protocols) {
+ return null;
+ }
+ };
+
+ final HttpRequestMapper> mapper = (request, context) -> supplier;
+ final WebSocketServerRequestHandler handler = new WebSocketServerRequestHandler(
+ mapper,
+ WebSocketConfig.DEFAULT,
+ WebSocketExtensionRegistry.createDefault());
+
+ final BasicClassicHttpRequest request = new BasicClassicHttpRequest("GET", "/ws");
+ request.addHeader(HttpHeaders.CONNECTION, "Upgrade");
+ request.addHeader(HttpHeaders.UPGRADE, "websocket");
+ request.addHeader(WebSocketConstants.SEC_WEBSOCKET_VERSION, "13");
+ request.addHeader(WebSocketConstants.SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ==");
+
+ final RecordingTrigger trigger = new RecordingTrigger();
+ final HttpContext context = HttpCoreContext.create();
+
+ final WebSocketServerConnection connection = createConnection();
+ context.setAttribute(WebSocketContextKeys.CONNECTION, connection);
+ handler.handle(request, trigger, context);
+
+ assertNotNull(trigger.response);
+ assertEquals(HttpStatus.SC_SWITCHING_PROTOCOLS, trigger.response.getCode());
+ assertEquals("websocket", trigger.response.getFirstHeader(HttpHeaders.UPGRADE).getValue());
+ assertTrue(opened.get());
+ connection.close();
+ }
+
+ @Test
+ void returnsUpgradeRequiredForNonUpgradeRequest() throws Exception {
+ final WebSocketServerRequestHandler handler = new WebSocketServerRequestHandler(
+ (request, context) -> () -> null,
+ WebSocketConfig.DEFAULT,
+ new WebSocketExtensionRegistry());
+
+ final BasicClassicHttpRequest request = new BasicClassicHttpRequest("GET", "/ws");
+ final RecordingTrigger trigger = new RecordingTrigger();
+ handler.handle(request, trigger, HttpCoreContext.create());
+
+ assertEquals(HttpStatus.SC_UPGRADE_REQUIRED, trigger.response.getCode());
+ }
+
+ private static WebSocketServerConnection createConnection() throws IOException {
+ final ServerSocket server = new ServerSocket(0);
+ final Socket client = new Socket("127.0.0.1", server.getLocalPort());
+ final Socket socket = server.accept();
+ client.close();
+ server.close();
+ final WebSocketServerConnectionFactory factory = new WebSocketServerConnectionFactory("http", null, null);
+ return factory.createConnection(socket);
+ }
+
+ private static final class RecordingTrigger implements ResponseTrigger {
+ ClassicHttpResponse response;
+
+ @Override
+ public void sendInformation(final ClassicHttpResponse response) {
+ this.response = response;
+ }
+
+ @Override
+ public void submitResponse(final ClassicHttpResponse response) {
+ this.response = response;
+ }
+ }
+}
diff --git a/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerTest.java b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerTest.java
new file mode 100644
index 0000000000..e0e34bd356
--- /dev/null
+++ b/httpclient5-websocket/src/test/java/org/apache/hc/core5/websocket/server/WebSocketServerTest.java
@@ -0,0 +1,45 @@
+/*
+ * ====================================================================
+ * 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.core5.websocket.server;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.apache.hc.core5.websocket.WebSocketHandler;
+import org.junit.jupiter.api.Test;
+
+class WebSocketServerTest {
+
+ @Test
+ void exposesServerInfo() {
+ final WebSocketServer server = WebSocketServerBootstrap.bootstrap()
+ .register("/ws", () -> new WebSocketHandler() {
+ })
+ .create();
+ assertNotNull(server);
+ server.getLocalPort();
+ }
+}
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..2ac4cdd382 100644
--- a/pom.xml
+++ b/pom.xml
@@ -62,7 +62,7 @@
1.8
1.8
- 5.4
+ 5.5-alpha1-SNAPSHOT
2.25.3
1.20.0
2.5.2
@@ -135,6 +135,11 @@
httpclient5-sse
${project.version}